diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2f38c66..986345a 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: - node-version: 16 + node-version: 20 - name: Install run: yarn @@ -23,10 +23,10 @@ jobs: - name: Test run: yarn test --detectOpenHandles --forceExit - + - name: Setup Custom Contexts shell: bash - run: | + run: | echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})" echo "##[set-output name=version;]$(cat ./package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')" echo "##[set-output name=short_sha;]$(git rev-parse --short HEAD)" diff --git a/.prettierrc b/.prettierrc index 0310d9f..30aaf01 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "tabWidth": 2, - "useTabs": true, - "trailingComma": "es5", - "singleQuote": true, - "printWidth": 120 -} \ No newline at end of file + "tabWidth": 2, + "useTabs": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120 +} diff --git a/README.md b/README.md index 2258cac..9b8f198 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,536 @@ -Server side component for monitoring WebRTC applications and services +# ObserverTC - Observer JS + +[![NPM version](https://img.shields.io/npm/v/@observertc/observer-js.svg)](https://www.npmjs.com/package/@observertc/observer-js) +[![License](https://img.shields.io/npm/l/@observertc/observer-js.svg)](https://github.com/observertc/observer-js/blob/main/LICENSE) + +`observer-js` is a Node.js library for monitoring WebRTC client data. It processes statistical samples from clients, organizes them into calls and participants, tracks a wide range of metrics, detects common issues, and calculates quality scores. This enables real-time insights into WebRTC session performance. + +This library is a core component of the ObserverTC ecosystem, designed to provide robust server-side monitoring capabilities for WebRTC applications. + +## Features + +- **Hierarchical Data Model**: Organizes data into `Observer` -> `ObservedCall` -> `ObservedClient` -> `ObservedPeerConnection` and further into streams and data channels. +- **Comprehensive Metrics**: Tracks a wide array of WebRTC statistics including RTT, jitter, packet loss, codecs, ICE states, TURN usage, bandwidth, and more. +- **Automatic Entity Management**: Can automatically create and manage call and client entities based on incoming data samples. +- **Issue Detection**: Built-in detectors for common WebRTC problems. +- **Quality Scoring**: Calculates quality scores for calls and clients. +- **Event-Driven**: Emits events for significant state changes, new entities, and detected issues. +- **Configurable Update Policies**: Flexible control over how and when metrics are processed and updated. +- **TypeScript Support**: Written in TypeScript, providing strong typing and intellisense. +- **Extensible**: Supports custom application data (`appData`) and integration with external schema definitions (e.g., `observertc/schemas`). + +## Installation + +```bash +npm install @observertc/observer-js +# or +yarn add @observertc/observer-js +``` + +## Quick Start + +```typescript +import { Observer, ObserverConfig } from '@observertc/observer-js'; +import { ClientSample } from '@observertc/schemas'; // Assuming you use the official schemas + +// 1. Configure the Observer +const observerConfig: ObserverConfig = { + updatePolicy: 'update-on-interval', + updateIntervalInMs: 5000, // Update observer every 5 seconds + defaultCallUpdatePolicy: 'update-on-any-client-updated', // Calls update when any client sends data +}; +const observer = new Observer(observerConfig); + +// 2. Listen to events +observer.on('newcall', (call) => { + console.log(`[Observer] New call detected: ${call.callId}`); + + call.on('newclient', (client) => { + console.log(`[Call: ${call.callId}] New client joined: ${client.clientId}`); + + client.on('issue', (issue) => { + console.warn(`[Client: ${client.clientId}] Issue: ${issue.type} - ${issue.severity} - ${issue.description}`); + }); + }); + + call.on('update', () => { + console.log( + `[Call: ${call.callId}] Metrics updated. Score: ${call.score?.toFixed(1)}, Clients: ${call.numberOfClients}` + ); + }); +}); + +// 3. Process Client Samples +// (ClientSample typically comes from your application after processing getStats() output) +function processClientStats(rawStats: any, callId: string, clientId: string) { + // Transform rawStats into the ClientSample format + // This is a placeholder for your actual transformation logic + const sample: ClientSample = { + callId, + clientId, + timestamp: Date.now(), + // ...populate with transformed stats from rawStats, adhering to the ClientSample schema + // from github.com/observertc/schemas + }; + observer.accept(sample); +} + +// Example usage: +// const myRawClientStats = getStatsFromClient(); +// processClientStats(myRawClientStats, 'myMeeting123', 'userABC'); + +// 4. Cleanup when done +// process.on('SIGINT', () => observer.close()); +``` + --- -Table of Contents: +## Detailed Documentation + +The following sections provide a comprehensive guide to `observer-js`. + +### 1. General Description + +(This section is identical to the introductory paragraph at the top of this README) + +### 2. Core Concepts + +#### 2.1. Data Flow + +1. **Client-Side**: Your application collects WebRTC statistics (e.g., via `RTCPeerConnection.getStats()`). +2. **Transformation**: These raw stats are transformed into the `ClientSample` schema (ideally from [observertc/schemas](https://github.com/observertc/schemas)). +3. **Ingestion**: The `ClientSample` is passed to the `observer.accept()` method. +4. **Processing**: `observer-js` processes the sample, updating or creating relevant entities (`ObservedCall`, `ObservedClient`, `ObservedPeerConnection`, etc.) and their metrics. +5. **Analysis**: Metrics are analyzed for issue detection and quality scoring. +6. **Events**: Events are emitted for significant state changes, new issues, or updates. + +#### 2.2. Entity Hierarchy - * [Quick Start](#quick-start) - * [Configurations](#configurations) - * [NPM package](#npm-package) - * [Schemas](#schemas) - * [License](#license) +- **`Observer`**: The root object, managing multiple calls and global settings. + - **`ObservedCall`**: Represents a distinct call session. + - **`ObservedClient`**: Represents an individual participant within a call. + - **`ObservedPeerConnection`**: Represents a WebRTC RTCPeerConnection of a client. + - **`ObservedInboundRtpStream` / `ObservedOutboundRtpStream`**: Tracks individual media streams. + - **`ObservedDataChannel`**: Tracks data channels. +- **`ObservedTURN`**: Tracks global TURN server usage metrics across the observer. -## Qucik Start +#### 2.3. Automatic Entity Creation -Install it from [npm](https://www.npmjs.com/package/@observertc/observer-js) package repository. +When `observer.accept(sample)` is called: +- If an `ObservedCall` for `sample.callId` doesn't exist, it's typically created. +- If an `ObservedClient` for `sample.clientId` within that call doesn't exist, it's typically created. +- Peer connections, streams, and data channels are similarly managed based on IDs in the sample. + +#### 2.4. Metrics Aggregation + +The library aggregates a wide array of metrics at each level of the hierarchy, including (but not limited to): + +- RTT, jitter, packet loss +- Bytes sent/received (audio, video, data) +- Codec information +- ICE connection details, TURN usage +- Stream/track states (muted, enabled) +- Frame rates, resolutions +- Bandwidth estimations + +#### 2.5. Issue Detection + +A `Detectors` system analyzes metrics to identify common WebRTC issues (e.g., high packet loss, low audio levels, frozen video, connection setup problems). Issues are reported via events. + +#### 2.6. Quality Scoring + +`ScoreCalculator` components assess the quality of calls and clients based on metrics and detected issues, typically resulting in a numerical score (e.g., 0.0 to 5.0). + +#### 2.7. Event-Driven Architecture + +The library uses Node.js `EventEmitter` to signal various occurrences, allowing applications to react to changes in real-time. + +### 3. API Reference + +#### 3.1. `Observer` + +Manages all monitored calls and global observer state. + +**Configuration (`ObserverConfig`)** + +```typescript +export type ObserverConfig = Record> = { + updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'update-on-interval'; + updateIntervalInMs?: number; // Used if updatePolicy is 'update-on-interval' + defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy']; + defaultCallUpdateIntervalInMs?: number; + appData?: AppData; // Custom data for this observer instance +}; ``` -npm i @observertc/observer-js + +**Constructor** + +```typescript +new Observer(config?: ObserverConfig) ``` -Use it in your server side NodeJS app. +- `config`: Optional. Defaults: `updatePolicy: 'update-when-all-call-updated'`. -```javascript -import { createObserver, ClientSample } from "@observertc/observer-js"; +**Key Properties** -const observer = createObserver({ - defaultServiceId: 'my-service-name', - defaultMediaUnitId: 'my-reporting-component', -}); +- `observedCalls: Map`: Active calls. +- `observedTURN: ObservedTURN`: Aggregated TURN metrics. +- `appData: AppData | undefined`: Custom application data. +- `closed: boolean`: True if `close()` has been called. +- Counters: `totalAddedCall`, `totalRemovedCall`, RTT buckets, `totalClientIssues`, `numberOfClientsUsingTurn`, `numberOfClients`, `numberOfPeerConnections`, etc. -const observedCall = observer.createObservedCall({ - roomId: 'roomId', - callId: 'room-session-id', -}); +**Key Methods** -const observedClient = observedCall.createObservedClient({ - clientId: 'client-id', - mediaUnitId: 'media-unit-id', -}); +- `createObservedCall(settings: ObservedCallSettings): ObservedCall` +- `getObservedCall(callId: string): ObservedCall | undefined` +- `accept(sample: ClientSample): void`: A convenience method to feed WebRTC stats. If `sample.callId` and `sample.clientId` are provided, it will route the sample to the appropriate `ObservedCall` and `ObservedClient`, creating them if they don't exist. The core sample processing for an existing client happens within the `ObservedClient`'s own `accept` or update mechanism. +- `update(): void`: Manually trigger an update cycle (behavior depends on `updatePolicy`). +- `close(): void`: Cleans up resources for the observer and all its calls. +- `createEventMonitor(ctx?: CTX): ObserverEventMonitor`: For contextual event listening. + +**Events (`ObserverEvents`)** + +- `'newcall' (call: ObservedCall)` +- `'call-updated' (call: ObservedCall)` +- `'client-event' (client: ObservedClient, event: ClientEvent)` +- `'client-issue' (client: ObservedClient, issue: ClientIssue)` +- `'client-metadata' (client: ObservedClient, metadata: ClientMetaData)` +- `'client-extension-stats' (client: ObservedClient, stats: ExtensionStat)` +- `'update' ()` +- `'close' ()` -const clientSample: ClientSample; // Receive your samples, for example, from a WebSocket +#### 3.2. `ObservedCall` -observedClient.accept(clientSample); +Represents a single call session. + +**Configuration (`ObservedCallSettings`)** + +```typescript +export type ObservedCallSettings = Record> = { + callId: string; + appData?: AppData; + updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'update-on-interval'; + updateIntervalInMs?: number; // Used if updatePolicy is 'update-on-interval' + remoteTrackResolvePolicy?: 'mediasoup-sfu'; // For specific SFU integration +}; ``` -The above example do as follows: - 1. create an observer to evaluate samples from clients and sfus - 2. create a client source object to accept client samples - 3. add an evaluator process to evaluate ended calls +**Key Properties** - ### Get a Summary of a call when it ends +- `callId: string` +- `appData: AppData | undefined` +- `numberOfClients: number` +- `score: number | undefined`: Overall call quality score. +- `observedClients: Map` +- Counters: `totalAddedClients`, `totalRemovedClients`, `numberOfIssues`, RTT buckets, total bytes sent/received (audio/video/data), etc. - ```javascript +**Key Methods** - const monitor = observer.createCallSummaryMonitor('summary', (summary) => { - console.log('Call Summary', summary); - }); - ``` +- `createObservedClient(settings: ObservedClientSettings): ObservedClient` +- `getObservedClient(clientId: string): ObservedClient | undefined` +- `update(): void` +- `close(): void` +- `createEventMonitor(ctx?: CTX): ObservedCallEventMonitor` - ### How Many Clients are using TURN? +**Events (Emitted via `ObservedCall` instance)** -```javascript -const monitor = observer.createTurnUsageMonitor('turn', (turn) => { - console.log('TURN', turn); -}); +- `'newclient' (client: ObservedClient)` +- `'empty' ()`: When the last client leaves. +- `'not-empty' ()`: When the first client joins an empty call. +- `'update' ()` +- `'close' ()` -// at any point of time you can get the current state of the turn usage +#### 3.3. `ObservedClient` -console.log('Currently ', monitor.clients.size, 'clients are using TURN'); +Represents a participant in a call. -// you can get the incoming and outgoing bytes of the TURN server -console.log(`${YOUR_TURN_SERVER_ADDRESS} usage:`, monitor.getUsage(YOUR_TURN_SERVER_ADDRESS)); +**Configuration (`ObservedClientSettings`)** +```typescript +export type ObservedClientSettings = Record> = { + clientId: string; + appData?: AppData; + // Potentially other client-specific settings +}; ``` -### Monitor Calls and Clients as they updated +**Key Properties** + +- `clientId: string` +- `call: ObservedCall`: Reference to the parent call. +- `appData: AppData | undefined` +- `score: number | undefined`: Client quality score. +- `numberOfPeerConnections: number` +- `usingTURN: boolean` +- `observedPeerConnections: Map` +- Counters: `numberOfIssues`, RTT buckets, total bytes sent/received, `availableIncomingBitrate`, `availableOutgoingBitrate`, etc. + +**Key Methods** + +- `accept(sample: ClientSample): void`: (Or a similar internal update method called by `Observer.accept` or `ObservedCall`) Processes a `ClientSample` specific to this client, updating its metrics, peer connections, streams, etc. This is the primary point where a client's detailed WebRTC statistics are processed. +- `createObservedPeerConnection(settings: ObservedPeerConnectionSettings): ObservedPeerConnection` +- `getObservedPeerConnection(peerConnectionId: string): ObservedPeerConnection | undefined` +- `update(): void` +- `close(): void` +- `createEventMonitor(ctx?: CTX): ObservedClientEventMonitor` + +**Events (Emitted via `ObservedClient` instance)** + +- `'joined' ()` +- `'left' ()` +- `'update' ()` +- `'close' ()` +- `'newpeerconnection' (pc: ObservedPeerConnection)` +- `'issue' (issue: ClientIssue)` (and other specific issue events) + +#### 3.4. `ObservedPeerConnection` + +Represents an `RTCPeerConnection`. + +- Tracks ICE connection state, data channel stats, stream stats. +- Holds `ObservedInboundRtpStream`, `ObservedOutboundRtpStream`, and `ObservedDataChannel` instances. + +#### 3.5. `ObservedInboundRtpStream` / `ObservedOutboundRtpStream` + +- Track metrics for individual media streams (audio/video) like codec, packets lost/received, jitter, bytes, etc. + +#### 3.6. `ObservedDataChannel` + +- Tracks metrics for data channels like state, messages sent/received, bytes. + +#### 3.7. `ClientSample` (Schema) + +This is the primary input data structure passed to `observer.accept()`. It's a comprehensive object that should mirror the information obtainable from WebRTC `getStats()` and other client-side states. Key fields include: + +- `callId`, `clientId`, `timestamp` +- `peerConnections: RTCPeerConnectionStats[]` +- `inboundRtpStreams: RTCInboundRtpStreamStats[]` +- `outboundRtpStreams: RTCOutboundRtpStreamStats[]` +- `remoteInboundRtpStreams: RTCRemoteInboundRtpStreamStats[]` +- `remoteOutboundRtpStreams: RTCRemoteOutboundRtpStreamStats[]` +- `dataChannels: RTCDataChannelStats[]` +- `iceLocalCandidates: RTCIceCandidateStats[]`, `iceRemoteCandidates: RTCIceCandidateStats[]`, `iceCandidatePairs: RTCIceCandidatePairStats[]` +- `mediaSources: RTCAudioSourceStats[] / RTCVideoSourceStats[]` +- `tracks: RTCMediaStreamTrackStats[]` +- `certificates: RTCCertificateStats[]` +- `codecs: RTCCodecStats[]` +- `transports: RTCIceTransportStats[]` (or similar depending on spec version) +- `browser`, `engine`, `platform`, `os` (client environment metadata) +- `userMediaErrors`, `iceConnectionStates`, `connectionStates` (client-reported events/states) +- `extensionStats` (for custom data) + +_(Refer to the [observertc/schemas](https://github.com/observertc/schemas) repository, particularly the `ClientSample.ts` definition, for the exact and complete structure.)_ + +### 4. Configuration Possibilities + +#### 4.1. Update Policies + +Control how frequently entities re-calculate metrics and emit `update` events. + +**Observer Level (`ObserverConfig.updatePolicy`)** + +- `'update-on-any-call-updated'`: Observer updates if any of its calls update. +- `'update-when-all-call-updated'`: Observer updates after all its calls update. (Default) +- `'update-on-interval'`: Observer updates at `ObserverConfig.updateIntervalInMs`. + +**Call Level (`ObservedCallSettings.updatePolicy` or `ObserverConfig.defaultCallUpdatePolicy`)** + +- `'update-on-any-client-updated'`: Call updates if any of its clients update. +- `'update-when-all-client-updated'`: Call updates after all its clients update. +- `'update-on-interval'`: Call updates at its `updateIntervalInMs`. + +#### 4.2. Intervals + +- `ObserverConfig.updateIntervalInMs` +- `ObserverConfig.defaultCallUpdateIntervalInMs` +- `ObservedCallSettings.updateIntervalInMs` + +#### 4.3. Application Data (`appData`) + +Associate custom context with `Observer`, `ObservedCall`, and `ObservedClient` instances using generics. + +```typescript +interface MyCallAppData { + meetingTitle: string; + scheduledAt: Date; +} +const call = observer.createObservedCall({ + callId: 'call1', + appData: { meetingTitle: 'Team Sync', scheduledAt: new Date() }, +}); +console.log(call.appData?.meetingTitle); +``` + +#### 4.4. `appData` vs. Attachments + +The `observer-js` library provides two primary ways to associate custom information with its entities: `appData` and `attachments`. Understanding their distinct purposes is key for effective use. + +**`appData` (Application Data)** + +- **Purpose**: `appData` is designed to hold structured, typed, and relatively static metadata about an entity (`Observer`, `ObservedCall`, `ObservedClient`). This data is typically set at the time of entity creation and is directly accessible as a property of the entity instance. +- **Typing**: It is strongly typed using generics (e.g., `Observer`). This provides type safety and autocompletion in TypeScript environments. +- **Mutability**: While technically mutable (if the object assigned is mutable), it's generally intended for information that defines or describes the entity and doesn't change frequently during its lifecycle. +- **Accessibility**: Directly accessible via `entity.appData`. +- **Use Cases**: + - Storing application-specific identifiers (e.g., `userId`, `roomId`, `meetingType`). + - Configuration flags relevant to how your application interprets this entity. + - Descriptive information (e.g., `clientDeviceType`, `callRegion`). + +**`attachments` (Arbitrary Attachments)** + +- **Purpose**: `attachments` (if implemented as a `Map` or similar mechanism on entities) are meant for associating arbitrary, often dynamic, or less structured data with an entity. This can be useful for temporary state, inter-plugin communication, or data that doesn't fit neatly into a predefined `appData` schema. +- **Typing**: Typically less strictly typed (e.g., `unknown` or `any` values in a Map). Consumers of attachments need to perform their own type checks or assertions. +- **Mutability**: Designed to be more dynamic. Attachments can be added, updated, or removed throughout the entity's lifecycle. +- **Accessibility**: Accessed via methods like `entity.setAttachment(key, value)`, `entity.getAttachment(key)`, `entity.removeAttachment(key)`. +- **Use Cases**: + - Storing temporary state calculated by one part of your application to be read by another (e.g., a custom issue detector plugin might attach intermediate findings). + - Caching results of expensive computations related to the entity. + - Allowing different modules or plugins to associate their own private data with an observer entity without needing to modify its core `appData` type. + - Storing large binary data or complex objects that are not part of the core descriptive metadata. + +**When to Use Which:** + +- Use **`appData`** for: + - Core, descriptive metadata that is known at creation time or changes infrequently. + - Data that benefits from strong typing and is integral to your application's understanding of the entity. +- Use **`attachments`** for: + - Dynamic, temporary, or loosely structured data. + - Data added by different, potentially independent, parts of your system or plugins. + - Information that doesn't need to be part of the primary, typed `appData` schema. + +If `attachments` are not yet a formal feature, this section can serve as a design consideration or be adapted if you introduce such a mechanism. If `attachments` are already present, ensure the description matches their actual implementation. + +#### 4.5. Remote Track Resolution + +For SFU scenarios, especially with MediaSoup: +`ObservedCallSettings.remoteTrackResolvePolicy: 'mediasoup-sfu'` + +### 5. Examples + +#### 5.1. Basic Observer Setup & Sample Ingestion + +```typescript +// filepath: /path/to/your/app.ts +import { Observer, ObserverConfig } from '@observertc/observer-js'; // Adjust path +import { ClientSample } from '@observertc/schemas'; // Adjust path if using official schemas + +const observerConfig: ObserverConfig = { + updatePolicy: 'update-on-interval', + updateIntervalInMs: 5000, + defaultCallUpdatePolicy: 'update-on-any-client-updated', +}; +const observer = new Observer(observerConfig); -```javascript observer.on('newcall', (call) => { - call.on('update', () => { - console.log('Call Updated', call.callId); - }); + console.log(`[Observer] New call: ${call.callId}`); + call.on('update', () => { + console.log(`[Call: ${call.callId}] Updated. Clients: ${call.numberOfClients}, Score: ${call.score?.toFixed(1)}`); + }); + call.on('newclient', (client) => { + console.log(`[Call: ${call.callId}] New client: ${client.clientId}`); + client.on('update', () => { + // console.log(`[Client: ${client.clientId}] Updated. Score: ${client.score?.toFixed(1)}`); + }); + client.on('issue', (issue) => { + console.warn(`[Client: ${client.clientId}] Issue: ${issue.type} - ${issue.severity} - ${issue.description}`); + }); + }); +}); - call.on('newclient', (client) => { +// Function to transform your app's WebRTC stats to ClientSample +function mapStatsToClientSample(appStats: any, callId: string, clientId: string): ClientSample { + // Detailed mapping logic here based on ClientSample.ts schema + // from github.com/observertc/schemas + return { + callId, + clientId, + timestamp: Date.now(), + // ... map all relevant stats fields ... + } as ClientSample; // Ensure all required fields are present +} + +// Example: Receiving stats and processing +const rawStatsFromClient = { + /* ... your client's getStats() output ... */ +}; +const callId = 'meeting-alpha-123'; +const clientId = 'user-xyz-789'; +const sample = mapStatsToClientSample(rawStatsFromClient, callId, clientId); +observer.accept(sample); + +// Later, on application shutdown: +// observer.close(); +``` - client.on('update', () => { - console.log('Client Updated', client.clientId); +#### 5.2. Manual Call and Client Creation - console.log(`The avaialble incoming bitrate for the client ${client.clientId} is: ${client.availableIncomingBitrate}`) - }); - }) +```typescript +// ... observer setup ... + +const call = observer.createObservedCall({ + callId: 'scheduled-webinar-456', + updatePolicy: 'update-on-interval', + updateIntervalInMs: 10000, }); + +const client1 = call.createObservedClient({ clientId: 'presenter-01' }); +// Samples for 'presenter-01' in call 'scheduled-webinar-456' will update this client. ``` -## NPM package +#### 5.3. Using Event Monitors for Contextual Logging + +```typescript +const call = observer.getObservedCall('meeting-alpha-123'); +if (call) { + const callMonitor = call.createEventMonitor({ callId: call.callId, started: new Date() }); + callMonitor.on('client-joined', (client, context) => { + console.log(`EVENT_MONITOR (${context.callId}): Client ${client.clientId} joined at ${new Date()}`); + }); + callMonitor.on('issue-detected', (client, issue, context) => { + console.error(`EVENT_MONITOR (${context.callId}): Issue on ${client.clientId} - ${issue.description}`); + }); +} +``` -https://www.npmjs.com/package/@observertc/observer-js +### 6. Best Practices +- **Resource Management**: Always call `observer.close()`, `call.close()`, and `client.close()` when entities are no longer needed to free resources and stop timers. +- **Error Handling**: Wrap calls to library methods in `try...catch` blocks where appropriate, especially for operations that might throw errors based on state (e.g., creating an entity that already exists if not using `getOrCreate` patterns). +- **Event Listener Cleanup**: If dynamically adding/removing listeners, ensure they are properly removed (e.g., using `emitter.off()` or `emitter.removeListener()`) to prevent memory leaks, especially for short-lived monitored entities. +- **`ClientSample` Accuracy**: The quality of monitoring heavily depends on the completeness and correctness of the `ClientSample` data provided. Ensure thorough mapping from `getStats()`. +- **Update Policies**: Choose update policies carefully based on the desired granularity of updates and performance considerations. -## Schemas +### 7. Troubleshooting + +- **Memory Leaks**: Ensure `close()` is called on all entities. Check for unremoved event listeners. +- **No Events / Missing Updates**: + - Verify `observer.accept()` is being called with correctly formatted `ClientSample` data. + - Ensure `callId` and `clientId` in samples match expectations. + - Check if `updatePolicy` and `updateIntervalInMs` are configured as intended. +- **Debugging**: Utilize `console.log` within event handlers at different levels (Observer, Call, Client) to trace data flow and state changes. Use `appData` to add correlation IDs for easier debugging. + +### 8. TypeScript Support + +The library is written in TypeScript and provides type definitions. +Use generics with `Observer`, `ObservedCall`, and `ObservedClient` to type your custom `appData`. + +```typescript +interface MyClientAppData { + userId: string; + role: 'admin' | 'user'; +} +const client = call.createObservedClient({ + clientId: 'user1', + appData: { userId: 'u-123', role: 'admin' }, +}); +// client.appData will be typed as MyClientAppData | undefined +``` -https://github.com/observertc/schemas +### 9. Contributing +(Placeholder for contribution guidelines - e.g., link to CONTRIBUTING.md, coding standards, pull request process) -## License +### 10. License -Apache-2.0 +This project is licensed under the [MIT License](LICENSE). diff --git a/package.json b/package.json index 0ac7b96..4565bab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@observertc/observer-js", - "version": "0.42.9", + "version": "1.0.0-beta.1", "description": "Server Side NodeJS Library for processing ObserveRTC Samples", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -26,12 +26,11 @@ "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "1.1.1", - "@observertc/report-schemas-js": "2.2.10", - "@observertc/sample-schemas-js": "2.2.10", + "events": "^3.3.0", "uuid": "^8.3.2" }, "devDependencies": { - "@types/events": "^3.0.0", + "@tsconfig/node20": "^1.0.2", "@types/jest": "^27.5.2", "@types/node": "^20.9.0", "@types/uuid": "^8.3.4", @@ -44,6 +43,7 @@ "prettier": "2.8.7", "ts-jest": "^27.1.5", "typescript": "^4.8.3", + "ts-node-dev": "^2.0.0", "utf-8-validate": "^5.0.8" }, "repository": { diff --git a/src/ObservedCall.ts b/src/ObservedCall.ts index ffcf749..fa68b0e 100644 --- a/src/ObservedCall.ts +++ b/src/ObservedCall.ts @@ -1,26 +1,30 @@ import { EventEmitter } from 'events'; -import { ObservedClient, ObservedClientModel } from './ObservedClient'; +import { ObservedClient, ObservedClientSettings } from './ObservedClient'; import { Observer } from './Observer'; -import { createClientJoinedEventReport, createClientLeftEventReport } from './common/callEventReports'; -import { getMedian, PartialBy } from './common/utils'; -import { CallEventReport } from '@observertc/report-schemas-js'; -import { createProcessor } from './common/Middleware'; -import { ClientSample } from '@observertc/sample-schemas-js'; -import { ObservedOutboundAudioTrack } from './ObservedOutboundAudioTrack'; -import { ObservedOutboundVideoTrack } from './ObservedOutboundVideoTrack'; -import { CalculatedScore } from './common/CalculatedScore'; - -export type ObservedCallModel = { - serviceId: string; - roomId: string; +import { ScoreCalculator } from './scores/ScoreCalculator'; +import { CalculatedScore } from './scores/CalculatedScore'; +import { DefaultCallScoreCalculator } from './scores/DefaultCallScoreCalculator'; +import { Detectors } from './detectors/Detectors'; +import { RemoteTrackResolver } from './utils/RemoteTrackResolver'; +import { OnAllClientCallUpdater } from './updaters/OnAllClientCallUpdater'; +import { Updater } from './updaters/Updater'; +import { OnIntervalUpdater } from './updaters/OnIntervalUpdater'; +import { OnAnyClientCallUpdater } from './updaters/OnAnyClientCallUpdater'; +import { ObservedCallEventMonitor } from './ObservedCallEventMonitor'; + +export type ObservedCallSettings = Record> = { callId: string; + appData?: AppData; + remoteTrackResolvePolicy?: 'p2p' | 'mediasoup-sfu', + updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'update-on-interval', + updateIntervalInMs?: number, }; export type ObservedCallEvents = { update: [], newclient: [ObservedClient], - callevent: [Omit], empty: [], + 'not-empty': [], close: [], } @@ -32,195 +36,303 @@ export declare interface ObservedCall { } export class ObservedCall = Record> extends EventEmitter { - public readonly created = Date.now(); - - public readonly processor = createProcessor(); - - private readonly _clients = new Map(); + public readonly detectors: Detectors; + public updater?: Updater; + public scoreCalculator: ScoreCalculator; + public readonly callId: string; + public readonly observedClients = new Map(); + public readonly clientsUsedTurn = new Set(); + public readonly calculatedScore: CalculatedScore = { + weight: 1, + value: undefined, + }; + public remoteTrackResolver?: RemoteTrackResolver; - public _scoreData?: { - score: CalculatedScore, - calculated: number, + public totalAddedClients = 0; + public totalRemovedClients = 0; + + public totalClientsReceivedAudioBytes = 0; + public totalClientsReceivedVideoBytes = 0; + public totalClientsReceivedDataChannelBytes = 0; + public totalClientsReceivedBytes = 0; + + public totalClientsSentAudioBytes = 0; + public totalClientsSentDataChannelBytes = 0; + public totalClientsSentVideoBytes = 0; + public totalClientsSentBytes = 0; + + public totalRttLt50Measurements = 0; + public totalRttLt150Measurements = 0; + public totalRttLt300Measurements = 0; + public totalRttGtOrEq300Measurements = 0; + + public numberOfIssues = 0; + public numberOfPeerConnections = 0; + public numberOfInboundRtpStreams = 0; + public numberOfOutboundRtpStreams = 0; + public numberOfDataChannels = 0; + + public maxNumberOfClients = 0; + public deltaNumberOfIssues = 0; + + public deltaRttLt50Measurements = 0; + public deltaRttLt150Measurements = 0; + public deltaRttLt300Measurements = 0; + public deltaRttGtOrEq300Measurements = 0; + + public appData: AppData; + public closed = false; + public startedAt?: number; + public endedAt?: number; + public closedAt?: number; + + private _callStartedEvent: { + emitted: boolean, + timer?: ReturnType, + }; + private _callEndedEvent: { + emitted: boolean }; - - public readonly sfuStreamIdToOutboundAudioTrack = new Map(); - public readonly sfuStreamIdToOutboundVideoTrack = new Map(); - private _closed = false; - // this changes as clients emitting their joined event, as the call starts when the first client joins - public started?: number; - // this changes as clients emitting their left event, as the call ends when the last client leaves - public ended?: number; - - public observationUpdated = Date.now(); - public observationEnded?: number; - public readonly observationStarted = Date.now(); public constructor( - private readonly _model: ObservedCallModel, + settings: ObservedCallSettings, public readonly observer: Observer, - public readonly appData: AppData, ) { super(); this.setMaxListeners(Infinity); - } - public get serviceId(): string { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.serviceId!; - } + this.callId = settings.callId; + this.appData = settings.appData ?? {} as AppData; + this.scoreCalculator = new DefaultCallScoreCalculator(this); + this.detectors = new Detectors(); + + if (settings.updateIntervalInMs) { + if (settings.updatePolicy !== 'update-on-interval') { + throw new Error('updatePolicy must be update-on-interval if updateIntervalInMs is set in config'); + } + } + switch (settings.updatePolicy) { + case 'update-on-any-client-updated': + this.updater = new OnAnyClientCallUpdater(this); + break; + case 'update-when-all-client-updated': + this.updater = new OnAllClientCallUpdater(this); + break; + case 'update-on-interval': + if (!settings.updateIntervalInMs) { + throw new Error('updateIntervalInMs setting in config must be set if updatePolicy is update-on-interval'); + } + this.updater = new OnIntervalUpdater( + settings.updateIntervalInMs, + this.update.bind(this), + ); + break; + } - public get reports() { - return this.observer.reports; - } + switch (settings.remoteTrackResolvePolicy) { + case 'mediasoup-sfu': + break; + } - public get roomId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.roomId!; + this._callStartedEvent = { + emitted: false, + }; + this._callEndedEvent = { + emitted: false, + }; } - public get callId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.callId!; + public get numberOfClients() { + return this.observedClients.size; } - public get clients(): ReadonlyMap { - return this._clients; + public get score() { + return this.calculatedScore.value; } - public get score(): CalculatedScore | undefined { - const now = Date.now(); - - if (now - 15000 < (this._scoreData?.calculated ?? 0)) { - return this._scoreData?.score; - } else if (this.clients.size < 1) { - return { - remarks: [ { - severity: 'none', - text: 'No clients', - } ], - score: 5.0, - timestamp: now, - }; - } - const scores = [ ...this.clients.values() ].map((client) => client.score?.score ?? 5.0); - - this._scoreData = { - score: { - remarks: [], - score: getMedian(scores), - timestamp: now, - }, - calculated: now, - }; + public close() { + if (this.closed) return; + this.update(); // last update before closing + this.closed = true; - return this._scoreData.score; - } + this.updater?.close(); - public get closed() { - return this._closed; - } + let minSampleTimestamps: number | undefined; + let maxSampleTimestamps: number | undefined; - public close() { - if (this._closed) return; - this._closed = true; + for (const client of this.observedClients.values()) { + client.close(); - Array.from(this._clients.values()).forEach((client) => client.close()); + if (client.joinedAt) minSampleTimestamps = Math.min(minSampleTimestamps ?? client.joinedAt, client.joinedAt); + if (client.leftAt) maxSampleTimestamps = Math.max(maxSampleTimestamps ?? client.leftAt, client.leftAt); + } - this.observationEnded = Date.now(); - - this.emit('close'); - } + if (this.startedAt === undefined) this.startedAt = minSampleTimestamps; + if (this.endedAt === undefined) this.endedAt = maxSampleTimestamps; - public addEventReport(params: PartialBy, 'timestamp'>) { - this.reports.addCallEventReport({ - ...params, - serviceId: this.serviceId, - roomId: this.roomId, - callId: this.callId, - timestamp: params.timestamp ?? Date.now(), - }); + this.closedAt = Date.now(); + this.emit('close'); } - public createClient = Record>(config: ObservedClientModel & { - appData: ClientAppData, - // in case we generate the CLIENT_JOINED report we can set the timestamp of the event - // in that case we should set the client.left to the timestamp of the leaving. - // by default the timestamp is set to the current time - joinedTimestamp?: number, - createClientJoinedReport?: boolean, - createClientLeftReport?: boolean, - }) { - if (this._closed) throw new Error(`Call ${this.callId} is closed`); - - const { - appData, - createClientJoinedReport, - createClientLeftReport, - joinedTimestamp = Date.now(), - ...model - } = config; + public getObservedClient = Record>(clientId: string): ObservedClient | undefined { + if (this.closed || !this.observedClients.has(clientId)) return; - const result = new ObservedClient(model, this, appData); - const onUpdate = ({ sample }: { sample: ClientSample }) => { - this.observationUpdated = Date.now(); - if (result.joined) { - if (this.started === undefined || result.joined < this.started) { - this.started = result.joined; - } - } - if (result.left) { - if (this.ended === undefined || result.left > this.ended) { - this.ended = result.left; - } - } + return this.observedClients.get(clientId) as ObservedClient; + } - this.emit('update'); + public createObservedClient = Record>(settings: ObservedClientSettings): ObservedClient { + if (this.closed) throw new Error(`Call ${this.callId} is closed`); + if (this.observedClients.has(settings.clientId)) throw new Error(`Client with id ${settings.clientId} already exists`); - this.processor.process(sample); - }; + const result = new ObservedClient(settings, this); + const wasEmpty = this.observedClients.size === 0; + const onUpdate = () => this._onClientUpdate(result); + const joined = () => this._clientJoined(result); + const left = () => this._clientLeft(result); result.once('close', () => { result.off('update', onUpdate); - this._clients.delete(result.clientId); - - if (this._clients.size === 0) { + result.off('joined', joined); + result.off('left', left); + this.observedClients.delete(settings.clientId); + + if (this.observedClients.size === 0) { this.emit('empty'); } + ++this.totalRemovedClients; }); - result.on('update', onUpdate); - this._clients.set(result.clientId, result); - - if (createClientJoinedReport) { - this.reports.addCallEventReport(createClientJoinedEventReport( - this.serviceId, - result.mediaUnitId, - this.roomId, - this.callId, - result.clientId, - result.joined ?? joinedTimestamp, - result.userId, - result.marker, - )); - } + result.on('joined', joined); + result.on('left', left); + ++this.totalAddedClients; - result.once('close', () => { - if (createClientLeftReport) { - this.reports.addCallEventReport(createClientLeftEventReport( - this.serviceId, - result.mediaUnitId, - this.roomId, - this.callId, - result.clientId, - result.left ?? Date.now(), - result.userId, - result.marker, - )); - } - }); + this.observedClients.set(settings.clientId, result); + this.maxNumberOfClients = Math.max(this.maxNumberOfClients, this.observedClients.size); this.emit('newclient', result); + + if (wasEmpty) { + this.emit('not-empty'); + } return result; } + + public createEventMonitor(context: CTX): ObservedCallEventMonitor { + return new ObservedCallEventMonitor(this, context); + } + + public update() { + if (this.closed) return; + + this.numberOfInboundRtpStreams = 0; + this.numberOfOutboundRtpStreams = 0; + this.numberOfPeerConnections = 0; + this.numberOfDataChannels = 0; + + for (const client of this.observedClients.values()) { + this.numberOfInboundRtpStreams += client.numberOfInboundRtpStreams; + this.numberOfOutboundRtpStreams += client.numberOfOutboundRtpStreams; + this.numberOfPeerConnections += client.numberOfPeerConnections; + this.numberOfDataChannels += client.numberOfDataChannels; + } + + this.detectors.update(); + this.scoreCalculator.update(); + + this.emit('update'); + + this.deltaNumberOfIssues = 0; + this.deltaRttLt50Measurements = 0; + this.deltaRttLt150Measurements = 0; + this.deltaRttLt300Measurements = 0; + this.deltaRttGtOrEq300Measurements = 0; + } + + private _onClientUpdate(client: ObservedClient) { + this.totalClientsReceivedAudioBytes += client.deltaReceivedAudioBytes; + this.totalClientsReceivedVideoBytes += client.deltaReceivedVideoBytes; + this.totalClientsReceivedDataChannelBytes += client.deltaDataChannelBytesReceived; + this.totalClientsReceivedBytes += client.deltaTransportReceivedBytes; + this.totalClientsSentAudioBytes += client.deltaSentAudioBytes; + this.totalClientsSentVideoBytes += client.deltaSentVideoBytes; + this.totalClientsSentDataChannelBytes += client.deltaDataChannelBytesSent; + this.totalClientsSentBytes += client.deltaTransportSentBytes; + + this.deltaRttLt50Measurements += client.deltaRttLt50Measurements; + this.deltaRttLt150Measurements += client.deltaRttLt150Measurements; + this.deltaRttLt300Measurements += client.deltaRttLt300Measurements; + this.deltaRttGtOrEq300Measurements += client.deltaRttGtOrEq300Measurements; + + this.totalRttLt50Measurements += client.deltaRttLt50Measurements; + this.totalRttLt150Measurements += client.deltaRttLt150Measurements; + this.totalRttLt300Measurements += client.deltaRttLt300Measurements; + this.totalRttGtOrEq300Measurements += client.deltaRttGtOrEq300Measurements; + + this.deltaNumberOfIssues += client.deltaNumberOfIssues; + this.numberOfIssues += client.deltaNumberOfIssues; + + if (client.usingTURN) { + this.clientsUsedTurn.add(client.clientId); + } + } + + private _clientJoined(client: ObservedClient) { + if (!client.joinedAt) return; + + this.startedAt = Math.min(this.startedAt ?? client.joinedAt, client.joinedAt); + } + + private _clientLeft(client: ObservedClient) { + if (!client.leftAt) return; + + this.endedAt = Math.max(this.endedAt ?? client.leftAt, client.leftAt); + } + + // public resetSummaryMetrics() { + // this.totalAddedClients = 0; + // this.totalRemovedClients = 0; + + // this.totalClientsReceivedAudioBytes = 0; + // this.totalClientsReceivedVideoBytes = 0; + // this.totalClientsReceivedBytes = 0; + + // this.totalClientsSentAudioBytes = 0; + // this.totalClientsSentVideoBytes = 0; + // this.totalClientsSentBytes = 0; + + // this.totalRttLt50Measurements = 0; + // this.totalRttLt150Measurements = 0; + // this.totalRttLt300Measurements = 0; + // this.totalRttGtOrEq300Measurements = 0; + + // this.numberOfIssues = 0; + + // this.clientsUsedTurn.clear(); + + // } + + // public createSummary(): ObservedCallSummary { + // return { + // currentActiveClients: this.observedClients.size, + // totalAddedClients: this.totalAddedClients, + // totalRemovedClients: this.totalRemovedClients, + + // totalClientsReceivedAudioBytes: this.totalClientsReceivedBytes, + // totalClientsReceivedVideoBytes: this.totalClientsReceivedVideoBytes, + // totalClientsReceivedBytes: this.totalClientsReceivedBytes, + + // totalClientsSentAudioBytes: this.totalClientsSentAudioBytes, + // totalClientsSentVideoBytes: this.totalClientsSentVideoBytes, + // totalClientsSentBytes: this.totalClientsSentBytes, + + // totalRttLt50Measurements: this.totalRttLt50Measurements, + // totalRttLt150Measurements: this.totalRttLt150Measurements, + // totalRttLt300Measurements: this.totalRttLt300Measurements, + // totalRttGtOrEq300Measurements: this.totalRttGtOrEq300Measurements, + + // numberOfIssues: this.numberOfIssues, + // numberOfClientsUsedTurn: this.clientsUsedTurn.size, + // }; + // } } \ No newline at end of file diff --git a/src/ObservedCallEventMonitor.ts b/src/ObservedCallEventMonitor.ts new file mode 100644 index 0000000..6d390d5 --- /dev/null +++ b/src/ObservedCallEventMonitor.ts @@ -0,0 +1,392 @@ +import { ObservedCall } from './ObservedCall'; +import { ObservedCertificate } from './ObservedCertificate'; +import { ObservedClient, ObservedClientEvents } from './ObservedClient'; +import { ObservedCodec } from './ObservedCodec'; +import { ObservedDataChannel } from './ObservedDataChannel'; +import { ObservedIceCandidate } from './ObservedIceCandidate'; +import { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +import { ObservedIceTransport } from './ObservedIceTransport'; +import { ObservedInboundRtp } from './ObservedInboundRtp'; +import { ObservedInboundTrack } from './ObservedInboundTrack'; +import { ObservedMediaPlayout } from './ObservedMediaPlayout'; +import { ObservedMediaSource } from './ObservedMediaSource'; +import { ObservedOutboundRtp } from './ObservedOutboundRtp'; +import { ObservedOutboundTrack } from './ObservedOutboundTrack'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ClientEvent, ClientIssue, ClientMetaData, ClientSample, ExtensionStat } from './schema/ClientSample'; + +export class ObservedCallEventMonitor { + public constructor( + public readonly call: ObservedCall, + public readonly context: Context, + ) { + this._onPeerConnconnectionAdded = this._onPeerConnconnectionAdded.bind(this); + this._onPeerConnectionRemoved = this._onPeerConnectionRemoved.bind(this); + this._onCertificateAdded = this._onCertificateAdded.bind(this); + this._onCertificateRemoved = this._onCertificateRemoved.bind(this); + this._onInboundTrackAdded = this._onInboundTrackAdded.bind(this); + this._onInboundTrackRemoved = this._onInboundTrackRemoved.bind(this); + this._onOutboundTrackAdded = this._onOutboundTrackAdded.bind(this); + this._onOutboundTrackRemoved = this._onOutboundTrackRemoved.bind(this); + this._onInboundRtpAdded = this._onInboundRtpAdded.bind(this); + this._onInboundRtpRemoved = this._onInboundRtpRemoved.bind(this); + this._onOutboundRtpAdded = this._onOutboundRtpAdded.bind(this); + this._onOutboundRtpRemoved = this._onOutboundRtpRemoved.bind(this); + this._onDataChannelAdded = this._onDataChannelAdded.bind(this); + this._onDataChannelRemoved = this._onDataChannelRemoved.bind(this); + this._onAddedIceTransport = this._onAddedIceTransport.bind(this); + this._onRemovedIceTransport = this._onRemovedIceTransport.bind(this); + this._onIceCandidateAdded = this._onIceCandidateAdded.bind(this); + this._onIceCandidateRemoved = this._onIceCandidateRemoved.bind(this); + this._onAddedIceCandidatePair = this._onAddedIceCandidatePair.bind(this); + this._onRemovedIceCandidatePair = this._onRemovedIceCandidatePair.bind(this); + this._onAddedMediaCodec = this._onAddedMediaCodec.bind(this); + this._onRemovedMediaCodec = this._onRemovedMediaCodec.bind(this); + this._onAddedMediaPlayout = this._onAddedMediaPlayout.bind(this); + this._onRemovedMediaPlayout = this._onRemovedMediaPlayout.bind(this); + this._onMediaSourceAdded = this._onMediaSourceAdded.bind(this); + this._onMediaSourceRemoved = this._onMediaSourceRemoved.bind(this); + this._onClientIssue = this._onClientIssue.bind(this); + this._onClientMetadata = this._onClientMetadata.bind(this); + this._onClientJoined = this._onClientJoined.bind(this); + this._onClientLeft = this._onClientLeft.bind(this); + this._onUserMediaError = this._onUserMediaError.bind(this); + this._onUsingTurn = this._onUsingTurn.bind(this); + this._onClientAdded = this._onClientAdded.bind(this); + this._onCallAdded = this._onCallAdded.bind(this); + this._onClientRejoined = this._onClientRejoined.bind(this); + this._onClientExtensionStats = this._onClientExtensionStats.bind(this); + + this._onCallAdded(call); + } + + // Public event handlers + public onCallClosed?: (call: ObservedCall, ctx: Context) => void; + public onCallEmpty?: (call: ObservedCall, ctx: Context) => void; + public onCallNotEmpty?: (call: ObservedCall, ctx: Context) => void; + public onCallUpdated?: (call: ObservedCall, ctx: Context) => void; + + public onClientAdded?: (client: ObservedClient, ctx: Context) => void; + public onClientClosed?: (client: ObservedClient, ctx: Context) => void; + public onClientRejoined?: (client: ObservedClient, ctx: Context) => void; + public onClientIssue?: (observedClent: ObservedClient, issue: ClientIssue, ctx: Context) => void; + public onClientMetadata?: (observedClient: ObservedClient, metadata: ClientMetaData, ctx: Context) => void; + public onClientExtensionStats?: (observedClient: ObservedClient, extensionStats: ExtensionStat, ctx: Context) => void; + public onClientJoined?: (client: ObservedClient, ctx: Context) => void; + public onClientLeft?: (client: ObservedClient, ctx: Context) => void; + public onClientUserMediaError?: (observedClient: ObservedClient, error: string, ctx: Context) => void; + public onClientUsingTurn?: (client: ObservedClient, usingTurn: boolean, ctx: Context) => void; + public onClientUpdated?: (client: ObservedClient, sample: ClientSample, ctx: Context) => void; + public onClientEvent?: (client: ObservedClient, event: ClientEvent, ctx: Context) => void; + + public onPeerConnectionAdded?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onPeerConnectionRemoved?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onSelectedCandidatePairChanged?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onIceGatheringStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onIceConnectionStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onConnectionStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + + public onCertificateAdded?: (certificate: ObservedCertificate, ctx: Context) => void; + public onCertificateRemoved?: (certificate: ObservedCertificate, ctx: Context) => void; + + public onInboundTrackAdded?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + public onInboundTrackRemoved?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + + public onOutboundTrackAdded?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + public onOutboundTrackRemoved?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + + public onInboundRtpAdded?: (inboundRtp: ObservedInboundRtp, ctx: Context) => void; + public onInboundRtpRemoved?: (inboundRtp: ObservedInboundRtp, ctx: Context) => void; + + public onOutboundRtpAdded?: (outboundRtp: ObservedOutboundRtp, ctx: Context) => void; + public onOutboundRtpRemoved?: (outboundRtp: ObservedOutboundRtp, ctx: Context) => void; + + public onDataChannelAdded?: (dataChannel: ObservedDataChannel, ctx: Context) => void; + public onDataChannelRemoved?: (dataChannel: ObservedDataChannel, ctx: Context) => void; + + public onAddedIceTransport?: (iceTransport: ObservedIceTransport, ctx: Context) => void; + public onRemovedIceTransport?: (iceTransport: ObservedIceTransport, ctx: Context) => void; + + public onIceCandidateAdded?: (iceCandidate: ObservedIceCandidate, ctx: Context) => void; + public onIceCandidateRemoved?: (iceCandidate: ObservedIceCandidate, ctx: Context) => void; + + public onAddedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: Context) => void; + public onRemovedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: Context) => void; + + public onAddedMediaCodec?: (codec: ObservedCodec, ctx: Context) => void; + public onRemovedMediaCodec?: (codec: ObservedCodec, ctx: Context) => void; + + public onAddedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: Context) => void; + public onRemovedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: Context) => void; + + public onMediaSourceAdded?: (mediaSource: ObservedMediaSource, ctx: Context) => void; + public onMediaSourceRemoved?: (mediaSource: ObservedMediaSource, ctx: Context) => void; + + private _onCallAdded(call: ObservedCall) { + const onCallEmpty = () => this.onCallEmpty?.(call, this.context); + const onCallNotEmpty = () => this.onCallNotEmpty?.(call, this.context); + const onCallUpdated = () => this.onCallUpdated?.(call, this.context); + + call.once('close', () => { + call.off('newclient', this._onClientAdded); + call.off('empty', onCallEmpty); + call.off('not-empty', onCallNotEmpty); + call.off('update', onCallUpdated); + + this.onCallClosed?.(call, this.context); + + }); + call.on('newclient', this._onClientAdded); + call.on('empty', onCallEmpty); + call.on('not-empty', onCallNotEmpty); + call.on('update', onCallUpdated); + + } + + private _onClientAdded(observedClient: ObservedClient) { + const onClientIssue = (issue: ClientIssue) => this._onClientIssue(observedClient, issue); + const onClientMetadata = (metaData: ClientMetaData) => this._onClientMetadata(observedClient, metaData); + const onClientJoined = () => this._onClientJoined(observedClient); + const onClientLeft = () => this._onClientLeft(observedClient); + const onClientRejoined = () => this._onClientRejoined(observedClient); + const onClientExtensionStats = (extensionStats: ExtensionStat) => this._onClientExtensionStats(observedClient, extensionStats); + const onUsingTurn = (usingTurn: boolean) => this._onUsingTurn(observedClient, usingTurn); + const onUserMediaError = (error: string) => this._onUserMediaError(observedClient, error); + const onClientUpdated = (...args: ObservedClientEvents['update']) => this.onClientUpdated?.(observedClient, args[0].sample, this.context); + const onClientEvent = (event: ClientEvent) => this.onClientEvent?.(observedClient, event, this.context); + + observedClient.once('close', () => { + observedClient.off('newpeerconnection', this._onPeerConnconnectionAdded); + observedClient.off('issue', onClientIssue); + observedClient.off('metaData', onClientMetadata); + observedClient.off('joined', onClientJoined); + observedClient.off('left', onClientLeft); + observedClient.off('rejoined', onClientJoined); + observedClient.off('usermediaerror', onUserMediaError); + observedClient.off('usingturn', onUsingTurn); + observedClient.off('update', onClientUpdated); + observedClient.off('clientEvent', onClientEvent); + + this.onClientClosed?.(observedClient, this.context); + }); + + observedClient.on('newpeerconnection', this._onPeerConnconnectionAdded); + observedClient.on('issue', onClientIssue); + observedClient.on('metaData', onClientMetadata); + observedClient.on('joined', onClientJoined); + observedClient.on('left', onClientLeft); + observedClient.on('rejoined', onClientRejoined); + observedClient.on('usermediaerror', onUserMediaError); + observedClient.on('usingturn', onUsingTurn); + observedClient.on('extensionStats', onClientExtensionStats); + observedClient.on('update', onClientUpdated); + observedClient.on('clientEvent', onClientEvent); + + this.onClientAdded?.(observedClient, this.context); + } + + private _onClientRejoined(observedClient: ObservedClient) { + this.onClientRejoined?.(observedClient, this.context); + } + + private _onPeerConnconnectionAdded(peerConnection: ObservedPeerConnection) { + const onSelectedCandidatePairChanged = () => this.onSelectedCandidatePairChanged?.(peerConnection, this.context); + const onIceGatheringStateChange = () => this.onIceGatheringStateChange?.(peerConnection, this.context); + const onIceConnectionStateChange = () => this.onIceConnectionStateChange?.(peerConnection, this.context); + const onConnectionStateChange = () => this.onConnectionStateChange?.(peerConnection, this.context); + + peerConnection.once('close', () => { + peerConnection.off('added-certificate', this._onCertificateAdded); + peerConnection.off('removed-certificate', this._onCertificateRemoved); + peerConnection.off('added-inbound-track', this._onInboundTrackAdded); + peerConnection.off('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.off('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.off('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.off('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.off('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.off('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.off('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.off('added-data-channel', this._onDataChannelAdded); + peerConnection.off('removed-data-channel', this._onDataChannelRemoved); + peerConnection.off('added-ice-transport', this._onAddedIceTransport); + peerConnection.off('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.off('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.off('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.off('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.off('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.off('added-codec', this._onAddedMediaCodec); + peerConnection.off('removed-codec', this._onRemovedMediaCodec); + peerConnection.off('added-media-playout', this._onAddedMediaPlayout); + peerConnection.off('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.off('added-media-source', this._onMediaSourceAdded); + peerConnection.off('removed-media-source', this._onMediaSourceRemoved); + peerConnection.off('selectedcandidatepair', onSelectedCandidatePairChanged); + peerConnection.off('icegatheringstatechange', onIceGatheringStateChange); + peerConnection.off('iceconnectionstatechange', onIceConnectionStateChange); + peerConnection.off('connectionstatechange', onConnectionStateChange); + + this.onPeerConnectionRemoved?.(peerConnection, this.context); + }); + + peerConnection.on('added-certificate', this._onCertificateAdded); + peerConnection.on('removed-certificate', this._onCertificateRemoved); + peerConnection.on('added-inbound-track', this._onInboundTrackAdded); + peerConnection.on('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.on('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.on('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.on('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.on('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.on('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.on('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.on('added-data-channel', this._onDataChannelAdded); + peerConnection.on('removed-data-channel', this._onDataChannelRemoved); + peerConnection.on('added-ice-transport', this._onAddedIceTransport); + peerConnection.on('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.on('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.on('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.on('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.on('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.on('added-codec', this._onAddedMediaCodec); + peerConnection.on('removed-codec', this._onRemovedMediaCodec); + peerConnection.on('added-media-playout', this._onAddedMediaPlayout); + peerConnection.on('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.on('added-media-source', this._onMediaSourceAdded); + peerConnection.on('removed-media-source', this._onMediaSourceRemoved); + peerConnection.on('selectedcandidatepair', onSelectedCandidatePairChanged); + peerConnection.on('icegatheringstatechange', onIceGatheringStateChange); + peerConnection.on('iceconnectionstatechange', onIceConnectionStateChange); + peerConnection.on('connectionstatechange', onConnectionStateChange); + + this.onPeerConnectionAdded?.(peerConnection, this.context); + + } + + private _onPeerConnectionRemoved(peerConnection: ObservedPeerConnection) { + this.onPeerConnectionRemoved?.(peerConnection, this.context); + + } + + private _onCertificateAdded(certificate: ObservedCertificate) { + this.onCertificateAdded?.(certificate, this.context); + } + + private _onCertificateRemoved(certificate: ObservedCertificate) { + this.onCertificateRemoved?.(certificate, this.context); + } + + private _onInboundTrackAdded(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackAdded?.(inboundTrack, this.context); + } + + private _onInboundTrackRemoved(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackRemoved?.(inboundTrack, this.context); + } + + private _onOutboundTrackAdded(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackAdded?.(outboundTrack, this.context); + } + + private _onOutboundTrackRemoved(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackRemoved?.(outboundTrack, this.context); + } + + private _onInboundRtpAdded(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpAdded?.(inboundRtp, this.context); + } + + private _onInboundRtpRemoved(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpRemoved?.(inboundRtp, this.context); + } + + private _onOutboundRtpAdded(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpAdded?.(outboundRtp, this.context); + } + + private _onOutboundRtpRemoved(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpRemoved?.(outboundRtp, this.context); + } + + private _onDataChannelAdded(dataChannel: ObservedDataChannel) { + this.onDataChannelAdded?.(dataChannel, this.context); + } + + private _onDataChannelRemoved(dataChannel: ObservedDataChannel) { + this.onDataChannelRemoved?.(dataChannel, this.context); + } + + private _onAddedIceTransport(iceTransport: ObservedIceTransport) { + this.onAddedIceTransport?.(iceTransport, this.context); + } + + private _onRemovedIceTransport(iceTransport: ObservedIceTransport) { + this.onRemovedIceTransport?.(iceTransport, this.context); + } + + private _onIceCandidateAdded(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateAdded?.(iceCandidate, this.context); + } + + private _onIceCandidateRemoved(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateRemoved?.(iceCandidate, this.context); + } + + private _onAddedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onAddedIceCandidatePair?.(candidatePair, this.context); + } + + private _onRemovedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onRemovedIceCandidatePair?.(candidatePair, this.context); + } + + private _onAddedMediaCodec(codec: ObservedCodec) { + this.onAddedMediaCodec?.(codec, this.context); + } + + private _onRemovedMediaCodec(codec: ObservedCodec) { + this.onRemovedMediaCodec?.(codec, this.context); + } + + private _onAddedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onAddedMediaPlayout?.(mediaPlayout, this.context); + } + + private _onRemovedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onRemovedMediaPlayout?.(mediaPlayout, this.context); + } + + private _onMediaSourceAdded(mediaSource: ObservedMediaSource) { + this.onMediaSourceAdded?.(mediaSource, this.context); + } + + private _onMediaSourceRemoved(mediaSource: ObservedMediaSource) { + this.onMediaSourceRemoved?.(mediaSource, this.context); + } + + private _onClientIssue(observedClent: ObservedClient, issue: ClientIssue) { + this.onClientIssue?.(observedClent, issue, this.context); + } + + private _onClientMetadata(observedClient: ObservedClient, metadata: ClientMetaData) { + this.onClientMetadata?.(observedClient, metadata, this.context); + } + + private _onClientExtensionStats(observedClient: ObservedClient, extensionStats: ExtensionStat) { + this.onClientExtensionStats?.(observedClient, extensionStats, this.context); + } + + private _onClientJoined(observedClient: ObservedClient) { + this.onClientJoined?.(observedClient, this.context); + } + + private _onClientLeft(observedClient: ObservedClient) { + this.onClientLeft?.(observedClient, this.context); + } + + private _onUserMediaError(observedClient: ObservedClient, error: string) { + this.onClientUserMediaError?.(observedClient, error, this.context); + } + + private _onUsingTurn(observedClient: ObservedClient, usingTurn: boolean) { + this.onClientUsingTurn?.(observedClient, usingTurn, this.context); + } +} \ No newline at end of file diff --git a/src/ObservedCallSummary.ts b/src/ObservedCallSummary.ts new file mode 100644 index 0000000..842bc03 --- /dev/null +++ b/src/ObservedCallSummary.ts @@ -0,0 +1,22 @@ +export type ObservedCallSummary = { + currentActiveClients: number; + + totalAddedClients: number; + totalRemovedClients: number; + + totalClientsSentBytes: number, + totalClientsReceivedBytes: number, + totalClientsReceivedAudioBytes: number, + totalClientsReceivedVideoBytes: number, + totalClientsSentAudioBytes: number, + totalClientsSentVideoBytes: number, + + totalRttLt50Measurements: number, + totalRttLt150Measurements: number, + totalRttLt300Measurements: number, + totalRttGtOrEq300Measurements: number, + + numberOfIssues: number, + + numberOfClientsUsedTurn: number; +} \ No newline at end of file diff --git a/src/ObservedCertificate.ts b/src/ObservedCertificate.ts new file mode 100644 index 0000000..d2aa0a8 --- /dev/null +++ b/src/ObservedCertificate.ts @@ -0,0 +1,43 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { CertificateStats } from './schema/ClientSample'; + +export class ObservedCertificate implements CertificateStats { + public appData?: Record; + + private _visited = false; + + fingerprint?: string; + fingerprintAlgorithm?: string; + base64Certificate?: string; + issuerCertificateId?: string; + attachments?: Record; + + public constructor( + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public update(stats: CertificateStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.fingerprint = stats.fingerprint; + this.fingerprintAlgorithm = stats.fingerprintAlgorithm; + this.base64Certificate = stats.base64Certificate; + this.issuerCertificateId = stats.issuerCertificateId; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedClient.ts b/src/ObservedClient.ts index 40a43e9..1426cd9 100644 --- a/src/ObservedClient.ts +++ b/src/ObservedClient.ts @@ -1,123 +1,92 @@ -import { Browser, ClientSample, Engine, IceLocalCandidate, IceRemoteCandidate, MediaCodecStats, MediaDevice, OperationSystem, Platform } from '@observertc/sample-schemas-js'; -import { ObservedCall } from './ObservedCall'; import { EventEmitter } from 'events'; import { ObservedPeerConnection } from './ObservedPeerConnection'; import { createLogger } from './common/logger'; -import { CallMetaType, createCallMetaReport } from './common/CallMetaReports'; // eslint-disable-next-line camelcase -import { PartialBy, isValidUuid } from './common/utils'; -import { CallEventType } from './common/CallEventType'; -import { ObservedSfu } from './ObservedSfu'; -import { ClientIssue } from './monitors/CallSummary'; -import { CallEventReport } from '@observertc/report-schemas-js'; -import { CalculatedScore } from './common/CalculatedScore'; +import { ClientEvent, ClientMetaData, ClientSample, PeerConnectionSample, ClientIssue, ExtensionStat } from './schema/ClientSample'; +import * as MetaData from './schema/ClientMetaTypes'; +import { ClientEventTypes } from './schema/ClientEventTypes'; +import { ObservedCall } from './ObservedCall'; +import { ClientMetaTypes } from './schema/ClientMetaTypes'; +import { parseJsonAs } from './common/utils'; +import { CalculatedScore } from './scores/CalculatedScore'; +import { Detectors } from './detectors/Detectors'; const logger = createLogger('ObservedClient'); -export type ObservedClientModel= { +export type ObservedClientSettings = Record> = { clientId: string; - mediaUnitId: string; - userId?: string, - marker?: string, - operationSystem?: OperationSystem, - engine?: Engine, - platform?: Platform, - browser?: Browser, - coordinates?: { - latitude: number; - longitude: number; - }, + appData?: AppData; }; export type ObservedClientEvents = { - update: [{ - sample: ClientSample, - elapsedTimeInMs: number, - }], - close: [], - joined: [], - left: [], - newpeerconnection: [ObservedPeerConnection], - iceconnectionstatechange: [{ - peerConnection: ObservedPeerConnection, - state: string, - }], - icegatheringstatechange: [{ - peerConnection: ObservedPeerConnection, - state: string, - }], - connectionstatechange: [{ - peerConnection: ObservedPeerConnection, - state: string, - }], - selectedcandidatepair: [{ - peerConnection: ObservedPeerConnection, - localCandidate: IceLocalCandidate, - remoteCandidate: IceRemoteCandidate, - }], - issue: [ClientIssue], - usingturn: [boolean], - usermediaerror: [string], - rejoined: [{ - lastJoined: number, - }], - score: [CalculatedScore], + update: [sample: ClientSample, elapsedTimeInMs: number]; + close: []; + joined: []; + issue: [ClientIssue]; + metaData: [ClientMetaData]; + rejoined: [timestamp: number]; + left: []; + usingturn: [boolean]; + usermediaerror: [string]; + extensionStats: [ExtensionStat]; + clientEvent: [ClientEvent]; + + newpeerconnection: [ObservedPeerConnection]; }; -export declare interface ObservedClient = Record> { +export declare interface ObservedClient { on(event: U, listener: (...args: ObservedClientEvents[U]) => void): this; off(event: U, listener: (...args: ObservedClientEvents[U]) => void): this; once(event: U, listener: (...args: ObservedClientEvents[U]) => void): this; emit(event: U, ...args: ObservedClientEvents[U]): boolean; - readonly appData: AppData; } -type PendingPeerConnectionTimestamp = { - type: 'opened' | 'closed' - peerConnectionId: string; - timestamp: number; -} +export class ObservedClient = Record> extends EventEmitter { + public readonly detectors: Detectors; -type PendingMediaTrackTimestamp = { - type: 'added' | 'removed' - peerConnectionId: string; - mediaTrackId: string; - timestamp: number; -} + public readonly clientId: string; + public readonly observedPeerConnections = new Map(); + public readonly calculatedScore: CalculatedScore = { + weight: 1, + value: undefined, + }; + + public appData: AppData; + public attachments?: Record; -export class ObservedClient = Record> extends EventEmitter { - - public readonly created = Date.now(); public updated = Date.now(); - public sfuId?: string; - - private readonly _peerConnections = new Map(); - - private _closed = false; - - private _acceptedSamples = 0; - private _timeZoneOffsetInHours?: number; + public acceptedSamples = 0; + public closed = false; + public joinedAt?: number; + public leftAt?: number; + public closedAt?: number; + public lastSampleTimestamp?: number; // the timestamp of the CLIENT_JOINED event - private _joined?: number; - private _left?: number; - public lastStatsTimestamp?: number; + public operationSystem?: MetaData.OperationSystem; + public engine?: MetaData.Engine; + public platform?: MetaData.Platform; + public browser?: MetaData.Browser; + public mediaConstraints: string[] = []; - public score?: CalculatedScore; public usingTURN = false; + public usingTCP = false; public availableOutgoingBitrate = 0; public availableIncomingBitrate = 0; + public totalInboundPacketsLost = 0; public totalInboundPacketsReceived = 0; public totalOutboundPacketsSent = 0; public totalDataChannelBytesSent = 0; public totalDataChannelBytesReceived = 0; - public totalSentBytes = 0; - public totalReceivedBytes = 0; + public totalDataChannelMessagesSent = 0; + public totalDataChannelMessagesReceived = 0; public totalReceivedAudioBytes = 0; public totalReceivedVideoBytes = 0; public totalSentAudioBytes = 0; public totalSentVideoBytes = 0; + public totalSentBytes = 0; + public totalReceivedBytes = 0; public deltaReceivedAudioBytes = 0; public deltaReceivedVideoBytes = 0; @@ -125,1047 +94,695 @@ export class ObservedClient = Record = {}; + + public constructor(settings: ObservedClientSettings, public readonly call: ObservedCall) { super(); this.setMaxListeners(Infinity); - } - - public getSfu = Record>(): ObservedSfu | undefined { - const sfu = this.call.observer.observedSfus.get(this.sfuId ?? ''); - - if (!sfu) return; - - return sfu as ObservedSfu; - } - - public get joined() { - return this._joined; - } - - public set joined(value: number | undefined) { - this._joined = value; - - if (this._joined) { - this.emit('joined'); - } - } - - public get left() { - return this._left; - } - public set left(value: number | undefined) { - this._left = value; + this.clientId = settings.clientId; + this.appData = settings.appData ?? {} as AppData; - if (this._left) { - this.emit('left'); - } - } - - public get sfu(): ObservedSfu | undefined { - if (!this.sfuId) return; - - return this.call.observer.observedSfus.get(this.sfuId); - } - - public get coordinates() { - return this._model.coordinates; - } - - public set coordinates(value: { latitude: number; longitude: number } | undefined) { - this._model.coordinates = value; - } - - public get clientId(): string { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.clientId!; - } - - public get roomId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.call.roomId!; - } - - public get serviceId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.call.serviceId!; - } - - public get callId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.call.callId!; - } - - public get mediaUnitId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.mediaUnitId!; - } - - public get reports() { - return this.call.reports; - } - - public get userId() { - return this._model.userId; - } - - public set userId(userId: string | undefined) { - this._model.userId = userId; - } - - public get timeZoneOffsetInHours() { - return this._timeZoneOffsetInHours; - } - - public get marker() { - return this._model.marker; - } - - public get browser() { - return this._model.browser; - } - - public get engine() { - return this._model.engine; - } - - public get operationSystem() { - return this._model.operationSystem; - } - - public get platform() { - return this._model.platform; + this.detectors = new Detectors(); } - - public get acceptedSamples() { - return this._acceptedSamples; + + public get numberOfPeerConnections() { + return this.observedPeerConnections.size; } - public get closed() { - return this._closed; + public get score() { + return this.calculatedScore.value; } - public get peerConnections(): ReadonlyMap { - return this._peerConnections; - } - public close() { - if (this._closed) return; - this._closed = true; + if (this.closed) return; + this.closed = true; - Array.from(this._peerConnections.values()).forEach((peerConnection) => peerConnection.close()); + this._injections.clientEvents?.forEach((clientEvent) => this._processClientEvent(clientEvent)); + this._injections.clientIssues?.forEach((clientIssue) => this.addIssue(clientIssue)); + this._injections.extensionStats?.forEach((extensionStat) => this.addExtensionStats(extensionStat)); + this._injections.clientMetaItems?.forEach((clientMetaItem) => this.addMetadata(clientMetaItem)); - this.emit('close'); - } + Array.from(this.observedPeerConnections.values()).forEach((peerConnection) => peerConnection.close()); + if (!this.leftAt) { + this.leftAt = this.lastSampleTimestamp; - public addEventReport(params: PartialBy, 'timestamp'> & { attachments?: Record }) { - const { - attachments, - ...fields - } = params; - - this.reports.addCallEventReport({ - ...fields, - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.userId, - timestamp: params.timestamp ?? Date.now(), - marker: this.marker, - attachments: attachments ? JSON.stringify(attachments) : undefined, - }); - } - - public addExtensionStatsReport(extensionType: string, payload?: Record) { - this.reports.addClientExtensionReport({ - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.userId, - timestamp: Date.now(), - extensionType, - payload: JSON.stringify(payload), - marker: this.marker, - }); - } - - public addIssue(issue: ClientIssue) { - try { - - this.reports.addCallEventReport({ - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.userId, - - name: CallEventType.CLIENT_ISSUE, - value: issue.severity, - peerConnectionId: issue.peerConnectionId, - mediaTrackId: issue.trackId, - message: issue.description, - timestamp: issue.timestamp ?? Date.now(), - attachments: issue.attachments ? JSON.stringify(issue.attachments): undefined, - }); - } catch (err) { - logger.warn(`Error adding client issue: ${(err as Error)?.message}`); + if (this.leftAt) { + this.emit('left'); + } } + this.closedAt = Date.now(); - this._addAndEmitIssue(issue); + this.emit('close'); } public accept(sample: ClientSample): void { - if (this._closed) throw new Error(`Client ${this.clientId} is closed`); - if (sample.clientId && sample.clientId !== 'NULL' && sample.clientId !== this.clientId) { - throw new Error(`Sample client id (${sample.clientId}) does not match the client id of the observed client (${this.clientId})`); - } - const now = Date.now(); - - ++this._acceptedSamples; - if (0 < this.issues.length) { - // we reset the issues every time we accept a new sample - this.issues = []; - } - for (const peerConnection of this._peerConnections.values()) { - if (peerConnection.closed) continue; - peerConnection.resetMetrics(); - } + if (this.closed) throw new Error(`Client ${this.clientId} is closed`); - if (this._model.userId) { - if (sample.userId && sample.userId !== 'NULL' && sample.userId !== this._model.userId) { - this._model.userId = sample.userId; - } - } else if (sample.userId && sample.userId !== 'NULL') { - this._model.userId = sample.userId; - } + const now = Date.now(); + const elapsedInMs = now - this.updated; + const elapsedInSeconds = elapsedInMs / 1000; + let sumOfRtts = 0; + let numberOfRttMeasurements = 0; - if (this._model.marker !== sample.marker) { - this._model.marker = sample.marker; - this._peerConnections.forEach((peerConnection) => (peerConnection.marker = sample.marker)); - } + ++this.acceptedSamples; + + this.availableIncomingBitrate = 0; + this.availableOutgoingBitrate = 0; + this.deltaDataChannelBytesReceived = 0; + this.deltaDataChannelBytesSent = 0; + this.deltaDataChannelMessagesReceived = 0; + this.deltaDataChannelMessagesSent = 0; + this.deltaInboundPacketsLost = 0; + this.deltaInboundPacketsReceived = 0; + this.deltaOutboundPacketsSent = 0; + this.deltaReceivedAudioBytes = 0; + this.deltaReceivedVideoBytes = 0; + this.deltaSentAudioBytes = 0; + this.deltaSentVideoBytes = 0; + this.deltaTransportReceivedBytes = 0; + this.deltaTransportSentBytes = 0; + this.deltaRttLt50Measurements = 0; + this.deltaRttLt150Measurements = 0; + this.deltaRttLt300Measurements = 0; + this.deltaRttGtOrEq300Measurements = 0; + this.deltaNumberOfIssues = 0; + + this.numberOfDataChannels = 0; + this.numberOfInboundRtpStreams = 0; + this.numberOfInbundTracks = 0; + this.numberOfOutboundRtpStreams = 0; + this.numberOfOutboundTracks = 0; + this.usingTURN = false; + this.usingTCP = false; + this.currentMinRttInMs = undefined; + this.currentMaxRttInMs = undefined; - if (this._timeZoneOffsetInHours !== sample.timeZoneOffsetInHours) { - this._timeZoneOffsetInHours = sample.timeZoneOffsetInHours; - } + this._mergeInjections(sample); - if (sample.os && ( - this._model.operationSystem?.name !== sample.os.name || - this._model.operationSystem?.version !== sample.os.version || - this._model.operationSystem?.versionName !== sample.os.versionName - )) { - this._model.operationSystem = sample.os; - - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.OPERATION_SYSTEM, - payload: sample.os, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); - } + const clientEventsPostBuffer: ClientEvent[] = []; - if (sample.engine && ( - this._model.engine?.name !== sample.engine.name || - this._model.engine?.version !== sample.engine.version - )) { - this._model.engine = sample.engine; - - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.ENGINE, - payload: sample.engine, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); - } + for (const clientEvent of sample.clientEvents ?? []) { + this._processClientEvent(clientEvent, clientEventsPostBuffer); - if (sample.platform && ( - this._model.platform?.model !== sample.platform.model || - this._model.platform?.type !== sample.platform.type || - this._model.platform?.vendor !== sample.platform.vendor - )) { - this._model.platform = sample.platform; - - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.PLATFORM, - payload: sample.platform, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); + this.call.observer.emit('client-event', this, clientEvent); } - if (sample.browser && ( - this._model.browser?.name !== sample.browser.name || - this._model.browser?.version !== sample.browser.version - )) { - this._model.browser = sample.browser; - - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.BROWSER, - payload: sample.browser, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); + for (const metaData of sample.clientMetaItems ?? []) { + this.addMetadata(metaData); } - for (const mediaConstraint of sample.mediaConstraints ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.MEDIA_CONSTRAINT, - payload: mediaConstraint, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); - this.mediaConstraints.push(mediaConstraint); - } + for (const issue of sample.clientIssues ?? []) { + this.addIssue(issue); - for (const localSDP of sample.localSDPs ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, - { - type: CallMetaType.LOCAL_SDP, - payload: localSDP, - }, - this.userId, - undefined, - sample.timestamp - ); - - this.reports.addCallMetaReport(callMetaReport); + ++this.deltaNumberOfIssues; } - for (const extensionStats of sample.extensionStats ?? []) { - this.reports.addClientExtensionReport({ - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.userId, - timestamp: now, - payload: extensionStats.payload, - extensionType: extensionStats.type, - }); + for (const extensionStat of sample.extensionStats ?? []) { + this.addExtensionStats(extensionStat); } - for (const { timestamp, ...callEvent } of sample.customCallEvents ?? []) { - switch (callEvent.name) { - case CallEventType.CLIENT_JOINED: { - const lastJoined = this.joined; + for (const pcSample of sample.peerConnections ?? []) { + const observedPeerConnection = this._updatePeerConnection(pcSample); - this.joined = timestamp; + if (!observedPeerConnection) continue; - // if it is joined before and it is joined again - if (lastJoined && this.joined && lastJoined !== this.joined) { - this.emit('rejoined', { lastJoined }); - } - // in case we have a left event before the joined event - if (this.left && this.joined && this.left < this.joined) { - this.left = undefined; - } - break; - } - case CallEventType.CLIENT_LEFT: { - this.left = timestamp; - break; - } - case CallEventType.PEER_CONNECTION_OPENED: { - callEvent.peerConnectionId && this.ωpendingPeerConnectionTimestamps.push({ - type: 'opened', - peerConnectionId: callEvent.peerConnectionId, - timestamp: timestamp ?? sample.timestamp, - }); - break; - } - case CallEventType.PEER_CONNECTION_CLOSED: { - callEvent.peerConnectionId && this.ωpendingPeerConnectionTimestamps.push({ - type: 'closed', - peerConnectionId: callEvent.peerConnectionId, - timestamp: timestamp ?? sample.timestamp, - }); - break; - } - case CallEventType.MEDIA_TRACK_ADDED: { - callEvent.peerConnectionId && callEvent.mediaTrackId && this.ωpendingMediaTrackTimestamps.push({ - type: 'added', - peerConnectionId: callEvent.peerConnectionId, - mediaTrackId: callEvent.mediaTrackId, - timestamp: timestamp ?? sample.timestamp, - }); - break; - } - case CallEventType.MEDIA_TRACK_REMOVED: { - callEvent.peerConnectionId && callEvent.mediaTrackId && this.ωpendingMediaTrackTimestamps.push({ - type: 'removed', - peerConnectionId: callEvent.peerConnectionId, - mediaTrackId: callEvent.mediaTrackId, - timestamp: timestamp ?? sample.timestamp, - }); - break; - } - case CallEventType.CLIENT_ISSUE: { - const severity = callEvent.value ? callEvent.value as ClientIssue['severity'] : 'minor'; - - try { - const issue: ClientIssue = { - severity, - timestamp: timestamp ?? Date.now(), - description: callEvent.message, - peerConnectionId: callEvent.peerConnectionId, - trackId: callEvent.mediaTrackId, - attachments: callEvent.attachments ? JSON.parse(callEvent.attachments) : undefined, - }; - - this._addAndEmitIssue(issue); - } catch (err) { - logger.warn(`Error parsing client issue: ${(err as Error)?.message}`); - } - break; - } - } - - this.reports.addCallEventReport({ - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.userId, - timestamp: timestamp ?? now, - ...callEvent, - }); - - this.call.emit('callevent', { - mediaUnitId: this.mediaUnitId, - clientId: this.clientId, - userId: this.userId, - timestamp: timestamp ?? now, - ...callEvent, - }); + this.deltaDataChannelBytesReceived += observedPeerConnection.deltaDataChannelBytesReceived; + this.deltaDataChannelBytesSent += observedPeerConnection.deltaDataChannelBytesSent; + this.deltaDataChannelMessagesReceived += observedPeerConnection.deltaDataChannelMessagesReceived; + this.deltaDataChannelMessagesSent += observedPeerConnection.deltaDataChannelMessagesSent; + this.deltaInboundPacketsLost += observedPeerConnection.deltaInboundPacketsLost; + this.deltaInboundPacketsReceived += observedPeerConnection.deltaInboundPacketsReceived; + this.deltaOutboundPacketsSent += observedPeerConnection.deltaOutboundPacketsSent; + this.deltaReceivedAudioBytes += observedPeerConnection.deltaReceivedAudioBytes; + this.deltaReceivedVideoBytes += observedPeerConnection.deltaReceivedVideoBytes; + this.deltaSentAudioBytes += observedPeerConnection.deltaSentAudioBytes; + this.deltaSentVideoBytes += observedPeerConnection.deltaSentVideoBytes; + this.deltaTransportReceivedBytes += observedPeerConnection.deltaTransportReceivedBytes; + this.deltaTransportSentBytes += observedPeerConnection.deltaTransportSentBytes; - } + this.availableIncomingBitrate += observedPeerConnection.availableIncomingBitrate; + this.availableOutgoingBitrate += observedPeerConnection.availableOutgoingBitrate; - for (const userMediaError of sample.userMediaErrors ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.USER_MEDIA_ERROR, - payload: userMediaError, - }, - this.userId, - ); - - this.reports.addCallMetaReport(callMetaReport); - this.userMediaErrors.push(userMediaError); - this.emit('usermediaerror', userMediaError); - } + this.numberOfDataChannels += observedPeerConnection.observedDataChannels.size; + this.numberOfInbundTracks += observedPeerConnection.observedInboundTracks.size; + this.numberOfOutboundRtpStreams += observedPeerConnection.observedOutboundRtps.size; + this.numberOfOutboundTracks += observedPeerConnection.observedOutboundTracks.size; + this.numberOfInboundRtpStreams += observedPeerConnection.observedInboundRtps.size; - for (const certificate of sample.certificates ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.CERTIFICATE, - payload: certificate, - }, - this.userId - ); - - this.reports.addCallMetaReport(callMetaReport); - } - - for (const codec of sample.codecs ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.CODEC, - payload: codec, - }, this.userId); - - this.reports.addCallMetaReport(callMetaReport); - if (codec.mimeType && !this.mediaCodecs.find((c) => c.mimeType === codec.mimeType)) { - this.mediaCodecs.push(codec); + if (observedPeerConnection.usingTURN) { + this.usingTURN = true; } - } - - for (const iceServer of sample.iceServers ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.ICE_SERVER, - payload: iceServer, - }, this.userId); - - this.reports.addCallMetaReport(callMetaReport); - } - - for (const mediaDevice of sample.mediaDevices ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.MEDIA_DEVICE, - payload: mediaDevice, - }, this.userId); - - this.reports.addCallMetaReport(callMetaReport); - if (mediaDevice.id && !this.mediaDevices.find((d) => d.id === mediaDevice.id)) { - this.mediaDevices.push(mediaDevice); + if (observedPeerConnection.usingTCP) { + this.usingTCP = true; } - } - - for (const mediaSource of sample.mediaSources ?? []) { - const callMetaReport = createCallMetaReport( - this.serviceId, - this.mediaUnitId, - this.roomId, - this.callId, - this.clientId, { - type: CallMetaType.MEDIA_SOURCE, - payload: mediaSource, - }, this.userId); - - this.reports.addCallMetaReport(callMetaReport); - } - for (const transport of sample.pcTransports ?? []) { - try { - const peerConnection = this._peerConnections.get(transport.transportId) ?? this._createPeerConnection(transport.peerConnectionId); + if (observedPeerConnection.currentRttInMs) { + if (this.currentMinRttInMs === undefined || observedPeerConnection.currentRttInMs < this.currentMinRttInMs) { + this.currentMinRttInMs = observedPeerConnection.currentRttInMs; + } + if (this.currentMaxRttInMs === undefined || observedPeerConnection.currentRttInMs > this.currentMaxRttInMs) { + this.currentMaxRttInMs = observedPeerConnection.currentRttInMs; + } + if (observedPeerConnection.currentRttInMs < 50) { + this.deltaRttLt50Measurements += 1; + } else if (observedPeerConnection.currentRttInMs < 150) { + this.deltaRttLt150Measurements += 1; + } else if (observedPeerConnection.currentRttInMs < 300) { + this.deltaRttLt300Measurements += 1; + } else if (300 <= observedPeerConnection.currentRttInMs) { + this.deltaRttGtOrEq300Measurements += 1; + } - !peerConnection.label && transport.label && (peerConnection.label = transport.label); - peerConnection.update(transport, sample.timestamp); - } catch (err) { - logger.error(`Error creating peer connection: ${(err as Error)?.message}`); + sumOfRtts += observedPeerConnection.currentRttInMs; + ++numberOfRttMeasurements; } - } - for (const track of sample.inboundAudioTracks ?? []) { - if (!track.peerConnectionId || !track.trackId) { - logger.warn(`InboundAudioTrack without peerConnectionId or trackId: ${JSON.stringify(track)}`); + for (const clientEvent of clientEventsPostBuffer) { + this._processClientEvent(clientEvent); + } - continue; - } + // emit new attachments? + this.attachments = sample.attachments; - try { - const peerConnection = this._peerConnections.get(track.peerConnectionId) ?? this._createPeerConnection(track.peerConnectionId); + this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived; + this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent; + this.totalDataChannelMessagesReceived += this.deltaDataChannelMessagesReceived; + this.totalDataChannelMessagesSent += this.deltaDataChannelMessagesSent; + this.totalInboundPacketsLost += this.deltaInboundPacketsLost; + this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived; + this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent; + this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes; + this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes; + this.totalSentAudioBytes += this.deltaSentAudioBytes; + this.totalSentVideoBytes += this.deltaSentVideoBytes; + this.totalReceivedBytes += this.deltaTransportReceivedBytes; + this.totalSentBytes += this.deltaTransportSentBytes; + this.totalRttLt50Measurements += this.deltaRttLt50Measurements; + this.totalRttLt150Measurements += this.deltaRttLt150Measurements; + this.totalRttLt300Measurements += this.deltaRttLt300Measurements; + this.totalRttGtOrEq300Measurements += this.deltaRttGtOrEq300Measurements; + this.totalNumberOfIssues += this.deltaNumberOfIssues; + + this.receivingAudioBitrate = (this.deltaReceivedAudioBytes * 8) / (elapsedInSeconds); + this.receivingVideoBitrate = (this.totalReceivedVideoBytes * 8) / (elapsedInSeconds); + this.sendingAudioBitrate = (this.deltaSentAudioBytes * 8) / (elapsedInSeconds); + this.sendingVideoBitrate = (this.deltaSentVideoBytes * 8) / (elapsedInSeconds); + this.currentAvgRttInMs = 0 < numberOfRttMeasurements ? sumOfRtts / numberOfRttMeasurements : undefined; + + this.calculatedScore.value = sample.score; + this.detectors.update(); + + this.lastSampleTimestamp = sample.timestamp; + // emit update + this.emit('update', + sample, + now - this.updated, + ); + this.updated = now; - if (!peerConnection) continue; - - const inboundAudioTrack = peerConnection.inboundAudioTracks.get(track.trackId) ?? peerConnection.createInboundAudioTrack({ - trackId: track.trackId, - sfuStreamId: track.sfuStreamId, - sfuSinkId: track.sfuSinkId, - }); - - inboundAudioTrack.update(track, sample.timestamp); - - if (!inboundAudioTrack.remoteOutboundTrack) { - const remoteOutboundTrack = this.call.sfuStreamIdToOutboundAudioTrack.get(inboundAudioTrack.sfuStreamId ?? ''); - - if (remoteOutboundTrack) { - inboundAudioTrack.remoteOutboundTrack = remoteOutboundTrack; - - inboundAudioTrack.once('close', () => { - remoteOutboundTrack?.remoteInboundTracks.delete(inboundAudioTrack.trackId ?? ''); - }); - remoteOutboundTrack.remoteInboundTracks.set(inboundAudioTrack.trackId ?? '', inboundAudioTrack); + // if result changed after update + if (this.calculatedScore.value) { + this.totalScoreSum += this.calculatedScore.value; + ++this.numberOfScoreMeasurements; + } + } + + private _processClientEvent(event: ClientEvent, postBuffer?: ClientEvent[]) { + // eslint-disable-next-line no-console + // console.warn('ClientEvent', event); + switch (event.type) { + case ClientEventTypes.CLIENT_JOINED: { + if (event.timestamp) { + if (!this.joinedAt) { + this.joinedAt = event.timestamp; + this.emit('joined'); + } else if (this.joinedAt < event.timestamp) { + this.emit('rejoined', event.timestamp); + } else { + this.joinedAt = event.timestamp; + logger.warn(`Client ${this.clientId} joinedAt timestamp was updated to ${event.timestamp}. the joined event will not be emitted.`); } } - } catch (err) { - logger.error(`Error creating inbound audio track: ${(err as Error)?.message}`); + logger.debug('Client %s joined at %o', this.clientId, event); + break; } - } - - for (const track of sample.inboundVideoTracks ?? []) { - if (!track.peerConnectionId || !track.trackId) { - logger.warn(`InboundVideoTrack without peerConnectionId or trackId: ${JSON.stringify(track)}`); + case ClientEventTypes.CLIENT_LEFT: { + if (event.timestamp) { + if (!this.leftAt) { + this.leftAt = event.timestamp; + this.emit('left'); + } else { + logger.warn(`Client ${this.clientId} leftAt timestamp was already set`); + } - continue; + } + logger.debug('Client %s left at %o', this.clientId, event); + break; } - if (isValidUuid(track.trackId) === false) { - // mediasoup-probator trackId is not a valid UUID, no need to warn about it - if (track.trackId === 'probator') continue; - logger.warn(`InboundVideoTrack with invalid trackId: ${track.trackId}`); - continue; + case ClientEventTypes.PEER_CONNECTION_OPENED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + + if (observedPeerConnection) { + observedPeerConnection.openedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received PEER_CONNECTION_OPENED event without a corresponding observedPeerConnection: %o', event); + } + + } + break; } - - try { - const peerConnection = this._peerConnections.get(track.peerConnectionId) ?? this._createPeerConnection(track.peerConnectionId); - - if (!peerConnection) continue; - - const inboundVideoTrack = peerConnection.inboundVideoTracks.get(track.trackId) ?? peerConnection.createInboundVideoTrack({ - trackId: track.trackId, - sfuStreamId: track.sfuStreamId, - sfuSinkId: track.sfuSinkId, - }); - - inboundVideoTrack.update(track, sample.timestamp); - - if (!inboundVideoTrack.remoteOutboundTrack) { - const remoteOutboundTrack = this.call.sfuStreamIdToOutboundVideoTrack.get(inboundVideoTrack.sfuStreamId ?? ''); - - if (remoteOutboundTrack) { - inboundVideoTrack.remoteOutboundTrack = remoteOutboundTrack; - - inboundVideoTrack.once('close', () => { - remoteOutboundTrack?.remoteInboundTracks.delete(inboundVideoTrack.trackId ?? ''); - }); - remoteOutboundTrack.remoteInboundTracks.set(inboundVideoTrack.trackId ?? '', inboundVideoTrack); + case ClientEventTypes.PEER_CONNECTION_CLOSED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + + if (observedPeerConnection) { + observedPeerConnection.closedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received PEER_CONNECTION_CLOSED event without a corresponding observedPeerConnection: %o', event); } } - } catch (err) { - logger.error(`Error creating inbound video track: ${(err as Error)?.message}`); + break; } - } - - for (const track of sample.outboundAudioTracks ?? []) { - if (!track.peerConnectionId || !track.trackId) { - logger.warn(`OutboundAudioTrack without peerConnectionId or trackId: ${JSON.stringify(track)}`); - - continue; + case ClientEventTypes.MEDIA_TRACK_ADDED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.trackId && typeof payload.trackId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedTrack = observedPeerConnection?.observedInboundTracks.get(payload.trackId) ?? observedPeerConnection?.observedOutboundTracks.get(payload.trackId); + + if (observedTrack) { + observedTrack.addedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received MEDIA_TRACK_ADDED event without a corresponding observedPeerConnection or observedTrack: %o', event); + } + } + break; } - - try { - const peerConnection = this._peerConnections.get(track.peerConnectionId) ?? this._createPeerConnection(track.peerConnectionId); - - if (!peerConnection) continue; - - const outboundAudioTrack = peerConnection.outboundAudioTracks.get(track.trackId) ?? peerConnection.createOutboundAudioTrack({ - trackId: track.trackId, - sfuStreamId: track.sfuStreamId, - }); - - outboundAudioTrack.update(track, sample.timestamp); - - if (outboundAudioTrack.sfuStreamId && !this.call.sfuStreamIdToOutboundAudioTrack.has(outboundAudioTrack.sfuStreamId)) { - this.call.sfuStreamIdToOutboundAudioTrack.set(outboundAudioTrack.sfuStreamId, outboundAudioTrack); + case ClientEventTypes.MEDIA_TRACK_REMOVED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.trackId && typeof payload.trackId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedTrack = observedPeerConnection?.observedInboundTracks.get(payload.trackId) ?? observedPeerConnection?.observedOutboundTracks.get(payload.trackId); + + if (observedTrack) { + observedTrack.removedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received MEDIA_TRACK_REMOVED event without a corresponding observedPeerConnection or observedTrack: %o', event); + } } - - } catch (err) { - logger.error(`Error creating outbound audio track: ${(err as Error)?.message}`); + break; } - - } - - for (const track of sample.outboundVideoTracks ?? []) { - if (!track.peerConnectionId || !track.trackId) { - logger.warn(`OutboundVideoTrack without peerConnectionId or trackId: ${JSON.stringify(track)}`); - - continue; + case ClientEventTypes.DATA_CHANNEL_OPEN: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.dataChannelId && typeof payload.dataChannelId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedDataChannel = observedPeerConnection?.observedDataChannels.get(payload.dataChannelId); + + if (observedDataChannel) { + observedDataChannel.addedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received DATA_CHANNEL_OPENED event without a corresponding observedPeerConnection or observedDataChannel: %o', event); + } + } + break; } - - try { - const peerConnection = this._peerConnections.get(track.peerConnectionId) ?? this._createPeerConnection(track.peerConnectionId); - - if (!peerConnection) continue; - - const outboundVideoTrack = peerConnection.outboundVideoTracks.get(track.trackId) ?? peerConnection.createOutboundVideoTrack({ - trackId: track.trackId, - sfuStreamId: track.sfuStreamId, - }); - - outboundVideoTrack.update(track, sample.timestamp); - - if (outboundVideoTrack.sfuStreamId && !this.call.sfuStreamIdToOutboundVideoTrack.has(outboundVideoTrack.sfuStreamId)) { - this.call.sfuStreamIdToOutboundVideoTrack.set(outboundVideoTrack.sfuStreamId, outboundVideoTrack); + case ClientEventTypes.DATA_CHANNEL_CLOSED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.dataChannelId && typeof payload.dataChannelId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedDataChannel = observedPeerConnection?.observedDataChannels.get(payload.dataChannelId); + + if (observedDataChannel) { + observedDataChannel.removedAt = event.timestamp; + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received DATA_CHANNEL_CLOSE event without a corresponding observedPeerConnection or observedDataChannel: %o', event); + } } - - } catch (err) { - logger.error(`Error creating outbound video track: ${(err as Error)?.message}`); + break; } - } + case ClientEventTypes.MEDIA_TRACK_MUTED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.trackId && typeof payload.trackId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedInboundTrack = observedPeerConnection?.observedInboundTracks.get(payload.trackId); + const observedOutboundTrack = observedPeerConnection?.observedOutboundTracks.get(payload.trackId); + + if (observedPeerConnection) { + if (observedInboundTrack) { + observedInboundTrack.muted = true; + observedPeerConnection?.emit('muted-inbound-track', observedInboundTrack); + } else if (observedOutboundTrack) { + observedOutboundTrack.muted = true; + observedPeerConnection?.emit('muted-outbound-track', observedOutboundTrack); + } + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received MEDIA_TRACK_MUTED event without a corresponding observedPeerConnection or observedTrack: %o', event); + } + } - for (const iceLocalCandidate of sample.iceLocalCandidates ?? []) { - if (!iceLocalCandidate.peerConnectionId) { - logger.warn(`Local ice candidate without peerConnectionId: ${JSON.stringify(iceLocalCandidate)}`); - continue; + break; } - const peerConnection = this._peerConnections.get(iceLocalCandidate.peerConnectionId); - - if (!peerConnection) { - logger.debug(`Peer connection ${iceLocalCandidate.peerConnectionId} not found for ice local candidate ${iceLocalCandidate.id}`); - continue; + case ClientEventTypes.MEDIA_TRACK_UNMUTED: { + const payload = parseJsonAs>(event.payload); + + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.trackId && typeof payload.trackId === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); + const observedInboundTrack = observedPeerConnection?.observedInboundTracks.get(payload.trackId); + const observedOutboundTrack = observedPeerConnection?.observedOutboundTracks.get(payload.trackId); + + if (observedPeerConnection) { + if (observedInboundTrack) { + observedInboundTrack.muted = false; + observedPeerConnection?.emit('unmuted-inbound-track', observedInboundTrack); + } else if (observedOutboundTrack) { + observedOutboundTrack.muted = false; + observedPeerConnection?.emit('unmuted-outbound-track', observedOutboundTrack); + } + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received MEDIA_TRACK_UNMUTED event without a corresponding observedPeerConnection or observedTrack: %o', event); + } + } + break; } + case ClientEventTypes.ICE_CONNECTION_STATE_CHANGED: { + const payload = parseJsonAs>(event.payload); - peerConnection.ICE.addLocalCandidate(iceLocalCandidate, sample.timestamp); - } - - for (const iceRemoteCandidate of sample.iceRemoteCandidates ?? []) { - if (!iceRemoteCandidate.peerConnectionId) { - logger.warn(`Remote ice candidate without peerConnectionId: ${JSON.stringify(iceRemoteCandidate)}`); - continue; - } - const peerConnection = this._peerConnections.get(iceRemoteCandidate.peerConnectionId); + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.iceConnectionState && typeof payload.iceConnectionState === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); - if (!peerConnection) { - logger.debug(`Peer connection ${iceRemoteCandidate.peerConnectionId} not found for ice remote candidate ${iceRemoteCandidate.id}`); - continue; + if (observedPeerConnection) { + observedPeerConnection.iceConnectionState = payload.iceConnectionState; + observedPeerConnection.emit('iceconnectionstatechange', { + state: payload.iceConnectionState, + }); + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received ICE_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o', event); + } + } + break; } + case ClientEventTypes.ICE_GATHERING_STATE_CHANGED: { + const payload = parseJsonAs>(event.payload); - peerConnection.ICE.addRemoteCandidate(iceRemoteCandidate, sample.timestamp); - } + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.iceGatheringState && typeof payload.iceGatheringState === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); - for (const candidatePair of sample.iceCandidatePairs ?? []) { - const peerConnection = this._peerConnections.get(candidatePair.peerConnectionId); + if (observedPeerConnection) { + observedPeerConnection.iceGatheringState = payload.iceGatheringState; + observedPeerConnection.emit('icegatheringstatechange', { + state: payload.iceGatheringState, + }); + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received ICE_GATHERING_STATE_CHANGED event without a corresponding observedPeerConnection: %o', event); + } + } + break; + } + case ClientEventTypes.PEER_CONNECTION_STATE_CHANGED: { + const payload = parseJsonAs>(event.payload); - if (!peerConnection) { - logger.debug(`Peer connection ${candidatePair.peerConnectionId} not found for ice candidate pair ${candidatePair.localCandidateId}, ${candidatePair.remoteCandidateId}`); + if (payload?.peerConnectionId && typeof payload.peerConnectionId === 'string' && payload?.peerConnectionState && typeof payload.peerConnectionState === 'string') { + const observedPeerConnection = this.observedPeerConnections.get(payload.peerConnectionId); - continue; + if (observedPeerConnection) { + observedPeerConnection.connectionState = payload.peerConnectionState; + observedPeerConnection.emit('connectionstatechange', { + state: payload.peerConnectionState, + }); + } else if (postBuffer) { + postBuffer.push(event); + } else { + logger.warn('Received PEER_CONNECTION_STATE_CHANGED event without a corresponding observedPeerConnection: %o', event); + } + } + break; } - - peerConnection.ICE.update(candidatePair, sample.timestamp); } + + this.emit('clientEvent', event); + } - for (const dataChannel of sample.dataChannels ?? []) { - if (!dataChannel.peerConnectionId || !dataChannel.dataChannelIdentifier) { - logger.warn(`DataChannel without peerConnectionId or dataChannelIdentifier: ${JSON.stringify(dataChannel)}`); + public injectMetaData(metaData: ClientMetaData) { + if (this.closed) return; + + if (!this._injections.clientMetaItems) this._injections.clientMetaItems = []; - continue; - } + this._injections.clientMetaItems.push(metaData); + } - try { - const peerConnection = this._peerConnections.get(dataChannel.peerConnectionId) ?? this._createPeerConnection(dataChannel.peerConnectionId); + public injectEvent(event: ClientEvent) { + if (this.closed) return; - if (!peerConnection) continue; - - const observedDataChannel = peerConnection.dataChannels.get(dataChannel.dataChannelIdentifier) ?? peerConnection.createDataChannel(dataChannel.dataChannelIdentifier); - - observedDataChannel.update(dataChannel, sample.timestamp); - } catch (err) { - logger.error(`Error creating data channel: ${(err as Error)?.message}`); - } - } + if (!this._injections.clientEvents) this._injections.clientEvents = []; - // try to set the timestamps of the peer connections - if (0 < this.ωpendingPeerConnectionTimestamps.length) { - const newPendingTimestamps: PendingPeerConnectionTimestamp[] = []; + this._injections.clientEvents.push(event); + } - for (const pendingTimestamp of this.ωpendingPeerConnectionTimestamps) { - const peerConnection = this._peerConnections.get(pendingTimestamp.peerConnectionId); + public injectIssue(issue: ClientIssue) { + if (this.closed) return; - if (!peerConnection) { - newPendingTimestamps.push(pendingTimestamp); - continue; - } - if (pendingTimestamp.type === 'opened') peerConnection.opened = pendingTimestamp.timestamp; - else if (pendingTimestamp.type === 'closed') peerConnection.closedTimestamp = pendingTimestamp.timestamp; - } + if (!this._injections.clientIssues) this._injections.clientIssues = []; - this.ωpendingPeerConnectionTimestamps = newPendingTimestamps; - } - - // try to set the timestamps of the media tracks - if (0 < this.ωpendingMediaTrackTimestamps.length) { - const newPendingTimestamps: PendingMediaTrackTimestamp[] = []; - - for (const pendingTimestamp of this.ωpendingMediaTrackTimestamps) { - const peerConnection = this._peerConnections.get(pendingTimestamp.peerConnectionId); - const mediaTrack = peerConnection?.inboundAudioTracks.get(pendingTimestamp.mediaTrackId) ?? - peerConnection?.inboundVideoTracks.get(pendingTimestamp.mediaTrackId) ?? - peerConnection?.outboundAudioTracks.get(pendingTimestamp.mediaTrackId) ?? - peerConnection?.outboundVideoTracks.get(pendingTimestamp.mediaTrackId); - - if (!mediaTrack) { - newPendingTimestamps.push(pendingTimestamp); - continue; - } + this._injections.clientIssues.push(issue); + } - if (pendingTimestamp.type === 'added') mediaTrack.added = pendingTimestamp.timestamp; - else if (pendingTimestamp.type === 'removed') mediaTrack.removed = pendingTimestamp.timestamp; - } + public injectExtensionStat(stat: ExtensionStat) { + if (this.closed) return; - this.ωpendingMediaTrackTimestamps = newPendingTimestamps; - } + if (!this._injections.extensionStats) this._injections.extensionStats = []; - // close resources that are not visited - for (const peerConnection of this._peerConnections.values()) { - if (!peerConnection.visited) { - peerConnection.close(); + this._injections.extensionStats.push(stat); + } - continue; - } + public injectAttachment(key: string, value: unknown) { + if (this.closed) return; - for (const track of peerConnection.inboundAudioTracks.values()) { - if (!track.visited) { - track.close(); + if (!this._injections.attachments) this._injections.attachments = {}; - continue; - } + this._injections.attachments[key] = value; + + } - track.visited = false; + public addMetadata(metadata: ClientMetaData) { + if (this.closed) return; + + switch (metadata.type) { + case ClientMetaTypes.BROWSER: { + this.browser = parseJsonAs(metadata.payload); + break; } - for (const track of peerConnection.inboundVideoTracks.values()) { - if (!track.visited) { - track.close(); - - continue; - } - - track.visited = false; + case ClientMetaTypes.ENGINE: { + this.engine = parseJsonAs(metadata.payload); + break; } - for (const track of peerConnection.outboundAudioTracks.values()) { - if (!track.visited) { - track.close(); - - continue; - } - - track.visited = false; + case ClientMetaTypes.PLATFORM: { + this.platform = parseJsonAs(metadata.payload); + break; } - for (const dataChannel of peerConnection.dataChannels.values()) { - if (!dataChannel.visited) { - dataChannel.close(); - - continue; - } - - dataChannel.visited = false; + case ClientMetaTypes.OPERATION_SYSTEM: { + this.operationSystem = parseJsonAs(metadata.payload); + break; } - - peerConnection.visited = false; } - // update metrics - const wasUsingTURN = this.usingTURN; - const elapsedTimeInMs = now - this.updated; - - this.usingTURN = false; - this.availableIncomingBitrate = 0; - this.availableOutgoingBitrate = 0; - this.deltaInboundPacketsLost = 0; - this.deltaInboundPacketsReceived = 0; - this.deltaOutboundPacketsSent = 0; - this.deltaReceivedAudioBytes = 0; - this.deltaReceivedVideoBytes = 0; - this.deltaSentAudioBytes = 0; - this.deltaSentVideoBytes = 0; - this.deltaDataChannelBytesReceived = 0; - this.deltaDataChannelBytesSent = 0; - - this.outboundAudioBitrate = 0; - this.outboundVideoBitrate = 0; - this.inboundAudioBitrate = 0; - this.inboundVideoBitrate = 0; - let sumRttInMs = 0; - let anyPeerConnectionUsingTurn = false; - - let minPcScore: number | undefined; - let maxPcScore: number | undefined; - let numberOfScoredPeerConnections = 0; - - for (const peerConnection of this._peerConnections.values()) { - if (peerConnection.closed) continue; - - peerConnection.updateMetrics(); - - this.deltaInboundPacketsLost += peerConnection.deltaInboundPacketsLost; - this.deltaInboundPacketsReceived += peerConnection.deltaInboundPacketsReceived; - this.deltaOutboundPacketsSent += peerConnection.deltaOutboundPacketsSent; - this.deltaReceivedAudioBytes += peerConnection.deltaReceivedAudioBytes; - this.deltaReceivedVideoBytes += peerConnection.deltaReceivedVideoBytes; - this.deltaSentAudioBytes += peerConnection.deltaSentAudioBytes; - this.deltaSentVideoBytes += peerConnection.deltaSentVideoBytes; - this.deltaDataChannelBytesReceived += peerConnection.deltaDataChannelBytesReceived; - this.deltaDataChannelBytesSent += peerConnection.deltaDataChannelBytesSent; + this.call.observer.emit('client-metadata', this, metadata); + } - this.availableIncomingBitrate += peerConnection.availableIncomingBitrate ?? 0; - this.availableOutgoingBitrate += peerConnection.availableOutgoingBitrate ?? 0; + public addIssue(issue: ClientIssue) { + if (this.closed) return; - sumRttInMs += peerConnection.avgRttInMs ?? 0; + this.emit('issue', issue); + this.call.observer.emit('client-issue', this, issue); + } - anyPeerConnectionUsingTurn ||= peerConnection.usingTURN; + public addExtensionStats(stats: ExtensionStat) { + this.call.observer.emit('client-extension-stats', this, stats); + this.emit('extensionStats', stats); + } - if (peerConnection.score) { - if (minPcScore === undefined || peerConnection.score.score < minPcScore) minPcScore = peerConnection.score.score; - if (maxPcScore === undefined || peerConnection.score.score > maxPcScore) maxPcScore = peerConnection.score.score; + private _updatePeerConnection(sample: PeerConnectionSample): ObservedPeerConnection | undefined { + let observedPeerConnection = this.observedPeerConnections.get(sample.peerConnectionId); - ++numberOfScoredPeerConnections; + if (!observedPeerConnection) { + if (!sample.peerConnectionId) { + return (logger.warn( + `ObservedClient received an invalid PeerConnectionSample (missing peerConnectionId field). ClientId: ${this.clientId}, CallId: ${this.call.callId}`, + sample + ), void 0); } - } - this.score = { - score: minPcScore ?? -1, - remarks: [ { - severity: 'none', - text: `Min and max score of all peer connections: ${minPcScore}, ${maxPcScore}, number of PeerConnections with scores: ${numberOfScoredPeerConnections}`, - } ], - timestamp: sample.timestamp, - }; - this.emit('score', this.score); - - this.usingTURN = anyPeerConnectionUsingTurn === true; - - if (wasUsingTURN !== this.usingTURN) this.emit('usingturn', this.usingTURN); + observedPeerConnection = new ObservedPeerConnection(sample.peerConnectionId, this); - this.totalSentBytes += this.deltaSentAudioBytes + this.deltaSentVideoBytes; - this.totalReceivedBytes += this.deltaReceivedAudioBytes + this.deltaReceivedVideoBytes; - this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes; - this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes; - this.totalSentAudioBytes += this.deltaSentAudioBytes; - this.totalSentVideoBytes += this.deltaSentVideoBytes; - this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent; - this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived; - this.totalInboundPacketsLost += this.deltaInboundPacketsLost; - this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent; - this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived; - - this.outboundAudioBitrate = (this.deltaSentAudioBytes * 8) / (Math.max(elapsedTimeInMs, 1) / 1000); - this.outboundVideoBitrate = (this.deltaSentVideoBytes * 8) / (Math.max(elapsedTimeInMs, 1) / 1000); - this.inboundAudioBitrate = (this.deltaReceivedAudioBytes * 8) / (Math.max(elapsedTimeInMs, 1) / 1000); - this.inboundVideoBitrate = (this.deltaReceivedVideoBytes * 8) / (Math.max(elapsedTimeInMs, 1) / 1000); + observedPeerConnection.once('close', () => { + this.observedPeerConnections.delete(sample.peerConnectionId); + }); + this.observedPeerConnections.set(sample.peerConnectionId, observedPeerConnection); + + this.emit('newpeerconnection', observedPeerConnection); + } - this.avgRttInMs = this._peerConnections.size ? sumRttInMs / this._peerConnections.size : undefined; - - // to make sure when sample is emitted it can be associated to this client - sample.clientId = this.clientId; + observedPeerConnection.accept(sample); - this.updated = now; - this.lastStatsTimestamp = sample.timestamp; - this.emit('update', { - sample, - elapsedTimeInMs, - }); + return observedPeerConnection; } - private _addAndEmitIssue(issue: ClientIssue) { - this.issues.push(issue); - - if (issue.peerConnectionId) { - const peerConnection = this._peerConnections.get(issue.peerConnectionId); + private _mergeInjections(sample: ClientSample): ClientSample { + if (this.closed) return sample; - if (peerConnection) { - - if (issue.trackId) { - const track = peerConnection.inboundAudioTracks.get(issue.trackId) ?? peerConnection.inboundVideoTracks.get(issue.trackId); - - if (track) track.ωpendingIssuesForScores.push(issue); - } else { - peerConnection.ωpendingIssuesForScores.push(issue); - } - } + if (this._injections.clientEvents) { + if (!sample.clientEvents) sample.clientEvents = []; + sample.clientEvents.push(...this._injections.clientEvents); + + this._injections.clientEvents = undefined; } - this.emit('issue', issue); - } + if (this._injections.clientIssues) { + if (!sample.clientIssues) sample.clientIssues = []; + sample.clientIssues.push(...this._injections.clientIssues); + + this._injections.clientIssues = undefined; + } - private _createPeerConnection(peerConnectionId: string, label?: string): ObservedPeerConnection { - const result = new ObservedPeerConnection({ - peerConnectionId, - label, - }, this); - - const onNewSelectedCandidatePair = ({ localCandidate, remoteCandidate }: { localCandidate: IceLocalCandidate, remoteCandidate: IceRemoteCandidate }) => { - this.emit('selectedcandidatepair', { - peerConnection: result, - localCandidate, - remoteCandidate, - }); - }; + if (this._injections.extensionStats) { + if (!sample.extensionStats) sample.extensionStats = []; + sample.extensionStats.push(...this._injections.extensionStats); + + this._injections.extensionStats = undefined; + } - result.once('close', () => { - result.ICE.off('new-selected-candidate-pair', onNewSelectedCandidatePair); - this._peerConnections.delete(peerConnectionId); - }); - result.ICE.on('new-selected-candidate-pair', onNewSelectedCandidatePair); - this._peerConnections.set(peerConnectionId, result); + if (this._injections.attachments) { + if (!sample.attachments) sample.attachments = {}; + Object.assign(sample.attachments, this._injections.attachments); + + this._injections.attachments = undefined; + } - this.emit('newpeerconnection', result); + if (this._injections.clientMetaItems) { + if (!sample.clientMetaItems) sample.clientMetaItems = []; + sample.clientMetaItems.push(...this._injections.clientMetaItems); + + this._injections.clientMetaItems = undefined; + } + + return sample; + } + + // public resetSummaryMetrics() { + // this.totalDataChannelBytesReceived = 0; + // this.totalDataChannelBytesSent = 0; + // this.totalDataChannelMessagesReceived = 0; + // this.totalDataChannelMessagesSent = 0; + // this.totalInboundPacketsLost = 0; + // this.totalInboundPacketsReceived = 0; + // this.totalOutboundPacketsSent = 0; + // this.totalReceivedAudioBytes = 0; + // this.totalReceivedVideoBytes = 0; + // this.totalSentAudioBytes = 0; + // this.totalSentVideoBytes = 0; + // this.totalSentBytes = 0; + // this.totalReceivedBytes = 0; + + // this.totalNumberOfIssues = 0; + + // this.totalScoreSum = 0; + // this.numberOfScoreMeasurements = 0; + // } + + // public createSummary(): ObservedClientSummary { + // return { + // totalRttLt50Measurements: this.totalRttLt50Measurements, + // totalRttLt150Measurements: this.totalRttLt150Measurements, + // totalRttLt300Measurements: this.totalRttLt300Measurements, + // totalRttGtOrEq300Measurements: this.totalRttGtOrEq300Measurements, + // totalDataChannelBytesReceived: this.totalDataChannelBytesReceived, + // totalDataChannelBytesSent: this.totalDataChannelBytesSent, + // totalDataChannelMessagesReceived: this.totalDataChannelMessagesReceived, + // totalDataChannelMessagesSent: this.totalDataChannelMessagesSent, + // totalInboundPacketsLost: this.totalInboundPacketsLost, + // totalInboundPacketsReceived: this.totalInboundPacketsReceived, + // totalOutboundPacketsSent: this.totalOutboundPacketsSent, + // totalReceivedAudioBytes: this.totalReceivedAudioBytes, + // totalReceivedVideoBytes: this.totalReceivedVideoBytes, + // totalSentAudioBytes: this.totalSentAudioBytes, + // totalSentVideoBytes: this.totalSentVideoBytes, + // totalSentBytes: this.totalSentBytes, + // totalReceivedBytes: this.totalReceivedBytes, + + // numberOfIssues: this.totalNumberOfIssues, - return result; - } -} \ No newline at end of file + // totalScoreSum: this.totalScoreSum, + // numberOfScoreMeasurements: this.numberOfScoreMeasurements, + // }; + // } +} diff --git a/src/ObservedClientEventMonitor.ts b/src/ObservedClientEventMonitor.ts new file mode 100644 index 0000000..7199779 --- /dev/null +++ b/src/ObservedClientEventMonitor.ts @@ -0,0 +1,309 @@ +import { ObservedCertificate } from './ObservedCertificate'; +import { ObservedClient, ObservedClientEvents } from './ObservedClient'; +import { ObservedCodec } from './ObservedCodec'; +import { ObservedDataChannel } from './ObservedDataChannel'; +import { ObservedIceCandidate } from './ObservedIceCandidate'; +import { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +import { ObservedIceTransport } from './ObservedIceTransport'; +import { ObservedInboundRtp } from './ObservedInboundRtp'; +import { ObservedInboundTrack } from './ObservedInboundTrack'; +import { ObservedMediaPlayout } from './ObservedMediaPlayout'; +import { ObservedMediaSource } from './ObservedMediaSource'; +import { ObservedOutboundRtp } from './ObservedOutboundRtp'; +import { ObservedOutboundTrack } from './ObservedOutboundTrack'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ClientIssue, ClientMetaData, ExtensionStat } from './schema/ClientSample'; + +export class ObservedClientEventMonitor = Record> { + public constructor( + public readonly observedClient: ObservedClient, + public readonly context: AppContext, + ) { + this._onPeerConnconnectionAdded = this._onPeerConnconnectionAdded.bind(this); + this._onPeerConnectionRemoved = this._onPeerConnectionRemoved.bind(this); + this._onCertificateAdded = this._onCertificateAdded.bind(this); + this._onCertificateRemoved = this._onCertificateRemoved.bind(this); + this._onInboundTrackAdded = this._onInboundTrackAdded.bind(this); + this._onInboundTrackRemoved = this._onInboundTrackRemoved.bind(this); + this._onOutboundTrackAdded = this._onOutboundTrackAdded.bind(this); + this._onOutboundTrackRemoved = this._onOutboundTrackRemoved.bind(this); + this._onInboundRtpAdded = this._onInboundRtpAdded.bind(this); + this._onInboundRtpRemoved = this._onInboundRtpRemoved.bind(this); + this._onOutboundRtpAdded = this._onOutboundRtpAdded.bind(this); + this._onOutboundRtpRemoved = this._onOutboundRtpRemoved.bind(this); + this._onDataChannelAdded = this._onDataChannelAdded.bind(this); + this._onDataChannelRemoved = this._onDataChannelRemoved.bind(this); + this._onAddedIceTransport = this._onAddedIceTransport.bind(this); + this._onRemovedIceTransport = this._onRemovedIceTransport.bind(this); + this._onIceCandidateAdded = this._onIceCandidateAdded.bind(this); + this._onIceCandidateRemoved = this._onIceCandidateRemoved.bind(this); + this._onAddedIceCandidatePair = this._onAddedIceCandidatePair.bind(this); + this._onRemovedIceCandidatePair = this._onRemovedIceCandidatePair.bind(this); + this._onAddedMediaCodec = this._onAddedMediaCodec.bind(this); + this._onRemovedMediaCodec = this._onRemovedMediaCodec.bind(this); + this._onAddedMediaPlayout = this._onAddedMediaPlayout.bind(this); + this._onRemovedMediaPlayout = this._onRemovedMediaPlayout.bind(this); + this._onMediaSourceAdded = this._onMediaSourceAdded.bind(this); + this._onMediaSourceRemoved = this._onMediaSourceRemoved.bind(this); + this._onClientClosed = this._onClientClosed.bind(this); + this._onClientIssue = this._onClientIssue.bind(this); + this._onClientMetadata = this._onClientMetadata.bind(this); + this._onClientJoined = this._onClientJoined.bind(this); + this._onClientLeft = this._onClientLeft.bind(this); + this._onUserMediaError = this._onUserMediaError.bind(this); + this._onUsingTurn = this._onUsingTurn.bind(this); + this._onClientUpdated = this._onClientUpdated.bind(this); + this._onClientExtensionStats = this._onClientExtensionStats.bind(this); + + this.observedClient.on('newpeerconnection', this._onPeerConnconnectionAdded); + this.observedClient.on('issue', this._onClientIssue); + this.observedClient.on('metaData', this._onClientMetadata); + this.observedClient.on('joined', this._onClientJoined); + this.observedClient.on('left', this._onClientLeft); + this.observedClient.on('rejoined', this._onClientJoined); + this.observedClient.on('usermediaerror', this._onUserMediaError); + this.observedClient.on('usingturn', this._onUsingTurn); + this.observedClient.on('update', this._onClientUpdated); + + this.observedClient.once('close', this._onClientClosed); + } + + public onClientUpdated?: (client: ObservedClient, event: ObservedClientEvents['update'][0], ctx: AppContext) => void; + private _onClientUpdated(...args: ObservedClientEvents['update']) { + this.onClientUpdated?.(this.observedClient, args[0], this.context); + } + + public onClientClosed?: (client: ObservedClient, ctx: AppContext) => void; + private _onClientClosed() { + this.onClientClosed?.(this.observedClient, this.context); + + this.observedClient.off('newpeerconnection', this._onPeerConnconnectionAdded); + this.observedClient.off('issue', this._onClientIssue); + this.observedClient.off('metaData', this._onClientMetadata); + this.observedClient.off('joined', this._onClientJoined); + this.observedClient.off('left', this._onClientLeft); + this.observedClient.off('rejoined', this._onClientJoined); + this.observedClient.off('usermediaerror', this._onUserMediaError); + this.observedClient.off('usingturn', this._onUsingTurn); + + } + + public onPeerConnectionAdded?: (peerConnection: ObservedPeerConnection, ctx: AppContext) => void; + private _onPeerConnconnectionAdded(peerConnection: ObservedPeerConnection) { + this.onPeerConnectionAdded?.(peerConnection, this.context); + + peerConnection.once('close', () => this._onPeerConnectionRemoved(peerConnection)); + + peerConnection.on('added-certificate', this._onCertificateAdded); + peerConnection.on('removed-certificate', this._onCertificateRemoved); + peerConnection.on('added-inbound-track', this._onInboundTrackAdded); + peerConnection.on('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.on('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.on('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.on('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.on('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.on('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.on('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.on('added-data-channel', this._onDataChannelAdded); + peerConnection.on('removed-data-channel', this._onDataChannelRemoved); + peerConnection.on('added-ice-transport', this._onAddedIceTransport); + peerConnection.on('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.on('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.on('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.on('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.on('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.on('added-codec', this._onAddedMediaCodec); + peerConnection.on('removed-codec', this._onRemovedMediaCodec); + peerConnection.on('added-media-playout', this._onAddedMediaPlayout); + peerConnection.on('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.on('added-media-source', this._onMediaSourceAdded); + peerConnection.on('removed-media-source', this._onMediaSourceRemoved); + + } + + public onPeerConnectionRemoved?: (peerConnection: ObservedPeerConnection, ctx: AppContext) => void; + private _onPeerConnectionRemoved(peerConnection: ObservedPeerConnection) { + this.onPeerConnectionRemoved?.(peerConnection, this.context); + + peerConnection.off('added-certificate', this._onCertificateAdded); + peerConnection.off('removed-certificate', this._onCertificateRemoved); + peerConnection.off('added-inbound-track', this._onInboundTrackAdded); + peerConnection.off('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.off('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.off('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.off('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.off('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.off('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.off('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.off('added-data-channel', this._onDataChannelAdded); + peerConnection.off('removed-data-channel', this._onDataChannelRemoved); + peerConnection.off('added-ice-transport', this._onAddedIceTransport); + peerConnection.off('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.off('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.off('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.off('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.off('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.off('added-codec', this._onAddedMediaCodec); + peerConnection.off('removed-codec', this._onRemovedMediaCodec); + peerConnection.off('added-media-playout', this._onAddedMediaPlayout); + peerConnection.off('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.off('added-media-source', this._onMediaSourceAdded); + peerConnection.off('removed-media-source', this._onMediaSourceRemoved); + + } + + public onCertificateAdded?: (certificate: ObservedCertificate, ctx: AppContext) => void; + private _onCertificateAdded(certificate: ObservedCertificate) { + this.onCertificateAdded?.(certificate, this.context); + } + + public onCertificateRemoved?: (certificate: ObservedCertificate, ctx: AppContext) => void; + private _onCertificateRemoved(certificate: ObservedCertificate) { + this.onCertificateRemoved?.(certificate, this.context); + } + + public onInboundTrackAdded?: (inboundTrack: ObservedInboundTrack, ctx: AppContext) => void; + private _onInboundTrackAdded(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackAdded?.(inboundTrack, this.context); + } + + public onInboundTrackRemoved?: (inboundTrack: ObservedInboundTrack, ctx: AppContext) => void; + private _onInboundTrackRemoved(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackRemoved?.(inboundTrack, this.context); + } + + public onOutboundTrackAdded?: (outboundTrack: ObservedOutboundTrack, ctx: AppContext) => void; + private _onOutboundTrackAdded(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackAdded?.(outboundTrack, this.context); + } + + public onOutboundTrackRemoved?: (outboundTrack: ObservedOutboundTrack, ctx: AppContext) => void; + private _onOutboundTrackRemoved(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackRemoved?.(outboundTrack, this.context); + } + + public onInboundRtpAdded?: (inboundRtp: ObservedInboundRtp, ctx: AppContext) => void; + private _onInboundRtpAdded(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpAdded?.(inboundRtp, this.context); + } + + public onInboundRtpRemoved?: (inboundRtp: ObservedInboundRtp, ctx: AppContext) => void; + private _onInboundRtpRemoved(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpRemoved?.(inboundRtp, this.context); + } + + public onOutboundRtpAdded?: (outboundRtp: ObservedOutboundRtp, ctx: AppContext) => void; + private _onOutboundRtpAdded(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpAdded?.(outboundRtp, this.context); + } + + public onOutboundRtpRemoved?: (outboundRtp: ObservedOutboundRtp, ctx: AppContext) => void; + private _onOutboundRtpRemoved(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpRemoved?.(outboundRtp, this.context); + } + + public onDataChannelAdded?: (dataChannel: ObservedDataChannel, ctx: AppContext) => void; + private _onDataChannelAdded(dataChannel: ObservedDataChannel) { + this.onDataChannelAdded?.(dataChannel, this.context); + } + + public onDataChannelRemoved?: (dataChannel: ObservedDataChannel, ctx: AppContext) => void; + private _onDataChannelRemoved(dataChannel: ObservedDataChannel) { + this.onDataChannelRemoved?.(dataChannel, this.context); + } + + public onAddedIceTransport?: (iceTransport: ObservedIceTransport, ctx: AppContext) => void; + private _onAddedIceTransport(iceTransport: ObservedIceTransport) { + this.onAddedIceTransport?.(iceTransport, this.context); + } + + public onRemovedIceTransport?: (iceTransport: ObservedIceTransport, ctx: AppContext) => void; + private _onRemovedIceTransport(iceTransport: ObservedIceTransport) { + this.onRemovedIceTransport?.(iceTransport, this.context); + } + + public onIceCandidateAdded?: (iceCandidate: ObservedIceCandidate, ctx: AppContext) => void; + private _onIceCandidateAdded(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateAdded?.(iceCandidate, this.context); + } + + public onIceCandidateRemoved?: (iceCandidate: ObservedIceCandidate, ctx: AppContext) => void; + private _onIceCandidateRemoved(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateRemoved?.(iceCandidate, this.context); + } + + public onAddedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: AppContext) => void; + private _onAddedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onAddedIceCandidatePair?.(candidatePair, this.context); + } + + public onRemovedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: AppContext) => void; + private _onRemovedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onRemovedIceCandidatePair?.(candidatePair, this.context); + } + + public onAddedMediaCodec?: (codec: ObservedCodec, ctx: AppContext) => void; + private _onAddedMediaCodec(codec: ObservedCodec) { + this.onAddedMediaCodec?.(codec, this.context); + } + + public onRemovedMediaCodec?: (codec: ObservedCodec, ctx: AppContext) => void; + private _onRemovedMediaCodec(codec: ObservedCodec) { + this.onRemovedMediaCodec?.(codec, this.context); + } + + public onAddedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: AppContext) => void; + private _onAddedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onAddedMediaPlayout?.(mediaPlayout, this.context); + } + + public onRemovedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: AppContext) => void; + private _onRemovedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onRemovedMediaPlayout?.(mediaPlayout, this.context); + } + + public onMediaSourceAdded?: (mediaSource: ObservedMediaSource, ctx: AppContext) => void; + private _onMediaSourceAdded(mediaSource: ObservedMediaSource) { + this.onMediaSourceAdded?.(mediaSource, this.context); + } + + public onMediaSourceRemoved?: (mediaSource: ObservedMediaSource, ctx: AppContext) => void; + private _onMediaSourceRemoved(mediaSource: ObservedMediaSource) { + this.onMediaSourceRemoved?.(mediaSource, this.context); + } + + public onClientIssue?: (issue: ClientIssue, ctx: AppContext) => void; + private _onClientIssue(issue: ClientIssue) { + this.onClientIssue?.(issue, this.context); + } + + public onClientMetadata?: (metadata: ClientMetaData, ctx: AppContext) => void; + private _onClientMetadata(metadata: ClientMetaData) { + this.onClientMetadata?.(metadata, this.context); + } + + public onClientExtensionStats?: (extensionStats: ExtensionStat, ctx: AppContext) => void; + private _onClientExtensionStats(extensionStats: ExtensionStat) { + this.onClientExtensionStats?.(extensionStats, this.context); + } + + public onClientJoined?: (client: ObservedClient, ctx: AppContext) => void; + private _onClientJoined() { + this.onClientJoined?.(this.observedClient, this.context); + } + + public onClientLeft?: (client: ObservedClient, ctx: AppContext) => void; + private _onClientLeft() { + this.onClientLeft?.(this.observedClient, this.context); + } + + public onUserMediaError?: (error: string, observedClient: ObservedClient, ctx: AppContext) => void; + private _onUserMediaError(error: string) { + this.onUserMediaError?.(error, this.observedClient, this.context); + } + + public onUsingTurn?: (client: ObservedClient, ctx: AppContext) => void; + private _onUsingTurn() { + this.onUsingTurn?.(this.observedClient, this.context); + } +} \ No newline at end of file diff --git a/src/ObservedClientSummary.ts b/src/ObservedClientSummary.ts new file mode 100644 index 0000000..a2c1b0d --- /dev/null +++ b/src/ObservedClientSummary.ts @@ -0,0 +1,24 @@ +export type ObservedClientSummary = { + totalInboundPacketsLost: number, + totalInboundPacketsReceived: number, + totalOutboundPacketsSent: number, + totalDataChannelBytesSent: number, + totalDataChannelBytesReceived: number, + totalDataChannelMessagesSent: number, + totalDataChannelMessagesReceived: number, + totalSentBytes: number, + totalReceivedBytes: number, + totalReceivedAudioBytes: number, + totalReceivedVideoBytes: number, + totalSentAudioBytes: number, + totalSentVideoBytes: number, + + totalRttLt50Measurements: number, + totalRttLt150Measurements: number, + totalRttLt300Measurements: number, + totalRttGtOrEq300Measurements: number, + totalScoreSum: number, + numberOfScoreMeasurements: number, + + numberOfIssues: number, +} \ No newline at end of file diff --git a/src/ObservedCodec.ts b/src/ObservedCodec.ts new file mode 100644 index 0000000..9b96997 --- /dev/null +++ b/src/ObservedCodec.ts @@ -0,0 +1,49 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { CodecStats } from './schema/ClientSample'; + +export class ObservedCodec implements CodecStats { + private _visited = false; + public appData?: Record; + + payloadType?: number | undefined; + transportId?: string | undefined; + clockRate?: number | undefined; + channels?: number | undefined; + sdpFmtpLine?: string | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + public mimeType: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getIceTransport() { + return this._peerConnection.observedIceTransports.get(this.transportId ?? ''); + } + + public update(stats: CodecStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.payloadType = stats.payloadType; + this.transportId = stats.transportId; + this.clockRate = stats.clockRate; + this.channels = stats.channels; + this.sdpFmtpLine = stats.sdpFmtpLine; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedDataChannel.ts b/src/ObservedDataChannel.ts index 6656d63..0442688 100644 --- a/src/ObservedDataChannel.ts +++ b/src/ObservedDataChannel.ts @@ -1,195 +1,85 @@ -import { EventEmitter } from 'events'; import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { DataChannel } from '@observertc/sample-schemas-js'; -import { ClientDataChannelReport } from '@observertc/report-schemas-js'; +import { DataChannelStats } from './schema/ClientSample'; export type ObservedDataChannelState = 'connecting' | 'open' | 'closing' | 'closed'; -export type ObservedDataChannelModel = { - channelId: number; -} - -export type ObservedDataChannelEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], -}; - -export type ObservedInboundTrackStats = { - ssrc: number; - bitrate: number; - fractionLost: number; - rttInMs?: number; - lostPackets?: number; - receivedPackets?: number; - receivedFrames?: number; - decodedFrames?: number; - droppedFrames?: number; - receivedSamples?: number; - silentConcealedSamples?: number; - fractionLoss?: number; -}; - -// { -// [Property in keyof ObservedInboundTrackStats]: ObservedInboundTrackStats[Property]; -// } - -export declare interface ObservedDataChannel { - on(event: U, listener: (...args: ObservedDataChannelEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedDataChannelEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedDataChannelEvents[U]) => void): this; - emit(event: U, ...args: ObservedDataChannelEvents[U]): boolean; - update(sample: DataChannel, timestamp: number): void; -} - -export class ObservedDataChannel extends EventEmitter { - public readonly created = Date.now(); - public visited = false; - - private _stats?: DataChannel; +export class ObservedDataChannel implements DataChannelStats { + private _visited = false; - private _closed = false; - private _updated = Date.now(); - public bitrate = 0; - public marker?: string; - - public totalReceivedMessages = 0; - public totalSentMessages = 0; - public totalBytesReceived = 0; - public totalBytesSent = 0; + label?: string | undefined; + protocol?: string | undefined; + dataChannelIdentifier?: number | undefined; + state?: string | undefined; + messagesSent?: number | undefined; + bytesSent?: number | undefined; + messagesReceived?: number | undefined; + bytesReceived?: number | undefined; + attachments?: Record | undefined; + + public addedAt?: number | undefined; + public removedAt?: number | undefined; - public deltaReceivedMessages = 0; - public deltaSentMessages = 0; - public deltaBytesReceived = 0; public deltaBytesSent = 0; + public deltaBytesReceived = 0; + public deltaMessagesSent = 0; + public deltaMessagesReceived = 0; + + public appData?: Record; public constructor( - private readonly _model: ObservedDataChannelModel, - public readonly peerConnection: ObservedPeerConnection, + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection, ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.peerConnection.serviceId; - } - - public get roomId() { - return this.peerConnection.roomId; - } - - public get callId() { - return this.peerConnection.callId; - } - - public get clientId() { - return this.peerConnection.clientId; - } - - public get mediaUnitId() { - return this.peerConnection.mediaUnitId; - } - - public get peerConnectionId() { - return this.peerConnection.peerConnectionId; - } - - public get channelId() { - return this._model.channelId; - } - - public get updated() { - return this._updated; - } - - public get state(): ObservedDataChannelState | undefined { - return this._stats?.state as ObservedDataChannelState | undefined; - } - - public get label() { - return this._stats?.label; - } - - public get protocol() { - return this._stats?.protocol; } - public get reports() { - return this.peerConnection.reports; - } - - public get stats() { - return this._stats; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - - this._closed = true; - - this.emit('close'); - } - - public resetMetrics() { - this.bitrate = 0; - this.totalReceivedMessages = 0; - this.totalSentMessages = 0; - this.totalBytesReceived = 0; - this.totalBytesSent = 0; - this.deltaReceivedMessages = 0; - this.deltaSentMessages = 0; - this.deltaBytesReceived = 0; - } - - public update(sample: DataChannel, timestamp: number) { - if (this._closed) return; - - const now = Date.now(); - - const report: ClientDataChannelReport = { - serviceId: this.peerConnection.client.call.serviceId, - roomId: this.peerConnection.client.call.roomId, - callId: this.peerConnection.client.call.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - ...sample, - timestamp, - sampleSeq: -1, - marker: this.marker, - }; - - this.reports.addClientDataChannelReport(report); - - sample.bytesReceived; - sample.bytesSent; - sample.messageReceived; - sample.messageSent; - - const elapsedTimeInMs = Math.max(1, now - this._updated); - - this.bitrate = ((sample.bytesReceived ?? 0) - (this._stats?.bytesReceived ?? 0)) * 8 / (elapsedTimeInMs / 1000); - this.deltaSentMessages = (sample.messageSent ?? 0) - (this._stats?.messageSent ?? 0); - this.deltaReceivedMessages = (sample.messageReceived ?? 0) - (this._stats?.messageReceived ?? 0); - this.deltaBytesSent = (sample.bytesSent ?? 0) - (this._stats?.bytesSent ?? 0); - this.deltaBytesReceived = (sample.bytesReceived ?? 0) - (this._stats?.bytesReceived ?? 0); - - this.totalBytesReceived = sample.bytesReceived ?? 0; - this.totalBytesSent = sample.bytesSent ?? 0; - this.totalReceivedMessages = sample.messageReceived ?? 0; - this.totalSentMessages = sample.messageSent ?? 0; - - this.visited = true; - this._stats = sample; - this._updated = timestamp; - this.emit('update', { - elapsedTimeInMs, - }); + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public update(stats: DataChannelStats) { + this._visited = true; + + if (this.messagesSent && stats.messagesSent && stats.messagesSent >= this.messagesSent) { + this.deltaMessagesSent = stats.messagesSent - this.messagesSent; + } else { + this.deltaMessagesSent = 0; + } + + if (this.messagesReceived && stats.messagesReceived && stats.messagesReceived >= this.messagesReceived) { + this.deltaMessagesReceived = stats.messagesReceived - this.messagesReceived; + } else { + this.deltaMessagesReceived = 0; + } + + if (this.bytesSent && stats.bytesSent && stats.bytesSent >= this.bytesSent) { + this.deltaBytesSent = stats.bytesSent - this.bytesSent; + } else { + this.deltaBytesSent = 0; + } + + if (this.bytesReceived && stats.bytesReceived && stats.bytesReceived >= this.bytesReceived) { + this.deltaBytesReceived = stats.bytesReceived - this.bytesReceived; + } else { + this.deltaBytesReceived = 0; + } + + this.label = stats.label; + this.protocol = stats.protocol; + this.dataChannelIdentifier = stats.dataChannelIdentifier; + this.state = stats.state; + this.messagesSent = stats.messagesSent; + this.bytesSent = stats.bytesSent; + this.messagesReceived = stats.messagesReceived; + this.bytesReceived = stats.bytesReceived; + this.attachments = stats.attachments; } } diff --git a/src/ObservedICE.ts b/src/ObservedICE.ts deleted file mode 100644 index 50d1fa8..0000000 --- a/src/ObservedICE.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { IceCandidatePair, IceLocalCandidate, IceRemoteCandidate } from '@observertc/sample-schemas-js'; -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { IceCandidatePairReport } from '@observertc/report-schemas-js'; -import { CallMetaType, createCallMetaReport } from './common/CallMetaReports'; - -export type ObservedICEEvents = { - update: [{ - elapsedTimeInMs: number; - }], - 'new-local-candidate': [IceLocalCandidate], - 'new-remote-candidate': [IceRemoteCandidate], - 'new-selected-candidate-pair': [{ - localCandidate: IceLocalCandidate, - remoteCandidate: IceRemoteCandidate, - }], - usingturnchanged: [boolean], - close: [], -}; - -export declare interface ObservedICE { - on(event: U, listener: (...args: ObservedICEEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedICEEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedICEEvents[U]) => void): this; - emit(event: U, ...args: ObservedICEEvents[U]): boolean; -} - -export class ObservedICE extends EventEmitter { - public static create(peerConnection: ObservedPeerConnection,) { - return new ObservedICE(peerConnection); - } - - public marker?: string; - - private readonly _localCandidates = new Map(); - private readonly _remoteCandidates = new Map(); - - public usingTURN = false; - - public deltaBytesReceived = 0; - public deltaBytesSent = 0; - public deltaPacketsReceived = 0; - public deltaPacketsSent = 0; - - public totalBytesReceived = 0; - public totalBytesSent = 0; - public totalPacketsReceived = 0; - public totalPacketsSent = 0; - - public currentRttInMs?: number; - - private _selectedLocalCandidateId?: string; - private _selectedRemoteCandidateId?: string; - private _updated = Date.now(); - private _stats?: IceCandidatePair; - private _closed = false; - - private constructor( - public readonly peerConnection: ObservedPeerConnection, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get reports() { - return this.peerConnection.reports; - } - - public get localCandidates(): ReadonlyMap { - return this._localCandidates; - } - - public get remoteCandidates(): ReadonlyMap { - return this._remoteCandidates; - } - - public get selectedLocalCandidate() { - return this._localCandidates.get(this._selectedLocalCandidateId ?? ''); - } - - public get selectedRemoteCandidate() { - return this._remoteCandidates.get(this._selectedRemoteCandidateId ?? ''); - } - - public get stats() { - return this._stats; - } - - public get updated() { - return this._updated; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - this.emit('close'); - } - - public addLocalCandidate(candidate: IceLocalCandidate, sampleTimestamp?: number) { - if (!candidate.id) return; - - const newCandidate = !this._localCandidates.has(candidate.id); - - this._localCandidates.set(candidate.id, candidate); - - if (newCandidate) { - const callMetaReport = createCallMetaReport( - this.peerConnection.client.serviceId, - this.peerConnection.client.mediaUnitId, - this.peerConnection.client.roomId, - this.peerConnection.client.callId, - this.peerConnection.client.clientId, { - type: CallMetaType.ICE_LOCAL_CANDIDATE, - payload: candidate, - }, - this.peerConnection.client.userId, - this.peerConnection.peerConnectionId, - sampleTimestamp, - ); - - this.reports.addCallMetaReport(callMetaReport); - - this.emit('new-local-candidate', candidate); - } - } - - public addRemoteCandidate(candidate: IceRemoteCandidate, sampleTimestamp?: number) { - if (!candidate.id) return; - - const newCandidate = !this._remoteCandidates.has(candidate.id); - - this._remoteCandidates.set(candidate.id, candidate); - - if (newCandidate) { - const callMetaReport = createCallMetaReport( - this.peerConnection.client.serviceId, - this.peerConnection.client.mediaUnitId, - this.peerConnection.client.roomId, - this.peerConnection.client.callId, - this.peerConnection.client.clientId, { - type: CallMetaType.ICE_REMOTE_CANDIDATE, - payload: candidate, - }, - this.peerConnection.client.userId, - this.peerConnection.peerConnectionId, - sampleTimestamp - ); - - this.reports.addCallMetaReport(callMetaReport); - - this.emit('new-remote-candidate', candidate); - } - } - - public resetMetrics() { - this.deltaBytesReceived = 0; - this.deltaBytesSent = 0; - this.deltaPacketsReceived = 0; - this.deltaPacketsSent = 0; - - this.currentRttInMs = undefined; - } - - public update(candidatePair: IceCandidatePair, timestamp: number) { - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - const report: IceCandidatePairReport = { - serviceId: this.peerConnection.client.serviceId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - roomId: this.peerConnection.client.roomId, - callId: this.peerConnection.client.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - timestamp, - ...candidatePair, - sampleSeq: -1, // deprecated - marker: this.marker, - }; - - this.reports.addIceCandidatePairReport(report); - - if (!candidatePair.nominated) return; - - this.deltaBytesReceived = (candidatePair.bytesReceived ?? 0) - (this._stats?.bytesReceived ?? 0); - this.deltaBytesSent = (candidatePair.bytesSent ?? 0) - (this._stats?.bytesSent ?? 0); - this.deltaPacketsReceived = (candidatePair.packetsReceived ?? 0) - (this._stats?.packetsReceived ?? 0); - this.deltaPacketsSent = (candidatePair.packetsSent ?? 0) - (this._stats?.packetsSent ?? 0); - - this.totalBytesReceived += this.deltaBytesReceived; - this.totalBytesSent += this.deltaBytesSent; - this.totalPacketsReceived += this.deltaPacketsReceived; - this.totalPacketsSent += this.deltaPacketsSent; - - this.currentRttInMs = candidatePair.currentRoundTripTime ? candidatePair.currentRoundTripTime * 1000 : undefined; - - this._stats = candidatePair; - - if ( - candidatePair.localCandidateId && - candidatePair.localCandidateId !== this._selectedLocalCandidateId && - this._localCandidates.has(candidatePair.localCandidateId) && - candidatePair.remoteCandidateId && - candidatePair.remoteCandidateId !== this._selectedRemoteCandidateId && - this._remoteCandidates.has(candidatePair.remoteCandidateId) - ) { - this._selectedLocalCandidateId = candidatePair.localCandidateId; - this._selectedRemoteCandidateId = candidatePair.remoteCandidateId; - - const localCandidate = this._localCandidates.get(candidatePair.localCandidateId); - const remoteCandidate = this._remoteCandidates.get(candidatePair.remoteCandidateId); - - if (localCandidate && remoteCandidate) { - const wasUsingTURN = this.usingTURN; - - this.usingTURN = (localCandidate.candidateType?.toLocaleLowerCase() === 'relay' ?? false) && (localCandidate.url?.startsWith('turn:') ?? false); - - this.emit('new-selected-candidate-pair', { - localCandidate, - remoteCandidate, - }); - - if (wasUsingTURN !== this.usingTURN) { - this.emit('usingturnchanged', this.usingTURN); - } - } - } - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } -} \ No newline at end of file diff --git a/src/ObservedIceCandidate.ts b/src/ObservedIceCandidate.ts new file mode 100644 index 0000000..4639ab3 --- /dev/null +++ b/src/ObservedIceCandidate.ts @@ -0,0 +1,64 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { IceCandidateStats } from './schema/ClientSample'; + +export class ObservedIceCandidate implements IceCandidateStats { + private _visited = false; + public appData?: Record; + + transportId?: string | undefined; + address?: string | undefined; + port?: number | undefined; + protocol?: string | undefined; + candidateType?: string | undefined; + priority?: number | undefined; + url?: string | undefined; + relayProtocol?: string | undefined; + foundation?: string | undefined; + relatedAddress?: string | undefined; + relatedPort?: number | undefined; + usernameFragment?: string | undefined; + tcpType?: string | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getIceTransport() { + return this._peerConnection.observedIceTransports.get(this.transportId ?? ''); + } + + public update(stats: IceCandidateStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.address = stats.address; + this.port = stats.port; + this.protocol = stats.protocol; + this.candidateType = stats.candidateType; + this.priority = stats.priority; + this.url = stats.url; + this.relayProtocol = stats.relayProtocol; + this.foundation = stats.foundation; + this.relatedAddress = stats.relatedAddress; + this.relatedPort = stats.relatedPort; + this.usernameFragment = stats.usernameFragment; + this.tcpType = stats.tcpType; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedIceCandidatePair.ts b/src/ObservedIceCandidatePair.ts new file mode 100644 index 0000000..8ea3ea6 --- /dev/null +++ b/src/ObservedIceCandidatePair.ts @@ -0,0 +1,134 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { IceCandidatePairStats } from './schema/ClientSample'; + +export class ObservedIceCandidatePair implements IceCandidatePairStats { + private _visited = false; + public appData?: Record; + + transportId?: string | undefined; + localCandidateId?: string | undefined; + remoteCandidateId?: string | undefined; + state?: 'new' | 'in-progress' | 'waiting' | 'failed' | 'succeeded' | undefined; + nominated?: boolean | undefined; + packetsSent?: number | undefined; + packetsReceived?: number | undefined; + bytesSent?: number | undefined; + bytesReceived?: number | undefined; + lastPacketSentTimestamp?: number | undefined; + lastPacketReceivedTimestamp?: number | undefined; + totalRoundTripTime?: number | undefined; + currentRoundTripTime?: number | undefined; + availableOutgoingBitrate?: number | undefined; + availableIncomingBitrate?: number | undefined; + requestsReceived?: number | undefined; + requestsSent?: number | undefined; + responsesReceived?: number | undefined; + responsesSent?: number | undefined; + consentRequestsSent?: number | undefined; + packetsDiscardedOnSend?: number | undefined; + bytesDiscardedOnSend?: number | undefined; + attachments?: Record | undefined; + + public deltaBytesSent = 0; + public deltaBytesReceived = 0; + public deltaPacketsSent = 0; + public deltaPacketsReceived = 0; + + public constructor( + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection, + ) { + } + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getIceTransport() { + return this._peerConnection.observedIceTransports.get(this.transportId ?? ''); + } + + public getLocalCandidate() { + return this._peerConnection.observedIceCandidates.get(this.localCandidateId ?? ''); + } + + public getRemoteCandidate() { + return this._peerConnection.observedIceCandidates.get(this.remoteCandidateId ?? ''); + } + + public update(stats: IceCandidatePairStats) { + this._visited = true; + + if (this.packetsSent && stats.packetsSent && stats.packetsSent >= this.packetsSent) { + this.deltaPacketsSent = stats.packetsSent - this.packetsSent; + } else { + this.deltaPacketsSent = 0; + } + if (this.packetsReceived && stats.packetsReceived && stats.packetsReceived >= this.packetsReceived) { + this.deltaPacketsReceived = stats.packetsReceived - this.packetsReceived; + } else { + this.deltaPacketsReceived = 0; + } + if (this.bytesSent && stats.bytesSent && stats.bytesSent >= this.bytesSent) { + this.deltaBytesSent = stats.bytesSent - this.bytesSent; + } else { + this.deltaBytesSent = 0; + } + if (this.bytesReceived && stats.bytesReceived && stats.bytesReceived >= this.bytesReceived) { + this.deltaBytesReceived = stats.bytesReceived - this.bytesReceived; + } else { + this.deltaBytesReceived = 0; + } + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.localCandidateId = stats.localCandidateId; + this.remoteCandidateId = stats.remoteCandidateId; + this.state = this._convertState(stats.state); + this.nominated = stats.nominated; + this.packetsSent = stats.packetsSent; + this.packetsReceived = stats.packetsReceived; + this.bytesSent = stats.bytesSent; + this.bytesReceived = stats.bytesReceived; + this.lastPacketSentTimestamp = stats.lastPacketSentTimestamp; + this.lastPacketReceivedTimestamp = stats.lastPacketReceivedTimestamp; + this.totalRoundTripTime = stats.totalRoundTripTime; + this.currentRoundTripTime = stats.currentRoundTripTime; + this.availableOutgoingBitrate = stats.availableOutgoingBitrate; + this.availableIncomingBitrate = stats.availableIncomingBitrate; + this.requestsReceived = stats.requestsReceived; + this.requestsSent = stats.requestsSent; + this.responsesReceived = stats.responsesReceived; + this.responsesSent = stats.responsesSent; + this.consentRequestsSent = stats.consentRequestsSent; + this.packetsDiscardedOnSend = stats.packetsDiscardedOnSend; + this.bytesDiscardedOnSend = stats.bytesDiscardedOnSend; + this.attachments = stats.attachments; + } + + private _convertState(state: string | undefined) { + switch (state) { + case 'new': + case 'in-progress': + case 'waiting': + case 'failed': + case 'succeeded': + return state; + case 'cancelled': + return 'failed'; + case 'inprogress': + return 'in-progress'; + default: + return undefined; + } + } +} \ No newline at end of file diff --git a/src/ObservedIceTransport.ts b/src/ObservedIceTransport.ts new file mode 100644 index 0000000..0f0c687 --- /dev/null +++ b/src/ObservedIceTransport.ts @@ -0,0 +1,99 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { IceTransportStats } from './schema/ClientSample'; + +export class ObservedIceTransport implements IceTransportStats { + private _visited = false; + public appData?: Record; + + packetsSent?: number | undefined; + packetsReceived?: number | undefined; + bytesSent?: number | undefined; + bytesReceived?: number | undefined; + iceRole?: string | undefined; + iceLocalUsernameFragment?: string | undefined; + dtlsState?: string | undefined; + iceState?: string | undefined; + selectedCandidatePairId?: string | undefined; + localCertificateId?: string | undefined; + remoteCertificateId?: string | undefined; + tlsVersion?: string | undefined; + dtlsCipher?: string | undefined; + dtlsRole?: string | undefined; + srtpCipher?: string | undefined; + selectedCandidatePairChanges?: number | undefined; + attachments?: Record | undefined; + + public deltaPacketsReceived = 0; + public deltaPacketsSent = 0; + public deltaBytesReceived = 0; + public deltaBytesSent = 0; + + public constructor( + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getSelectedCandidatePair() { + return this._peerConnection.observedIceCandidatesPair.get(this.selectedCandidatePairId ?? ''); + } + + public update(stats: IceTransportStats) { + this._visited = true; + + if (this.bytesReceived !== undefined && stats.bytesReceived !== undefined && this.bytesReceived <= stats.bytesReceived) { + this.deltaBytesReceived = stats.bytesReceived - this.bytesReceived; + } else { + this.deltaBytesReceived = 0; + } + + if (this.bytesSent !== undefined && stats.bytesSent !== undefined && this.bytesSent <= stats.bytesSent) { + this.deltaBytesSent = stats.bytesSent - this.bytesSent; + } else { + this.deltaBytesSent = 0; + } + + if (this.packetsReceived !== undefined && stats.packetsReceived !== undefined && this.packetsReceived <= stats.packetsReceived) { + this.deltaPacketsReceived = stats.packetsReceived - this.packetsReceived; + } else { + this.deltaPacketsReceived = 0; + } + + if (this.packetsSent !== undefined && stats.packetsSent !== undefined && this.packetsSent <= stats.packetsSent) { + this.deltaPacketsSent = stats.packetsSent - this.packetsSent; + } else { + this.deltaPacketsSent = 0; + } + + this.timestamp = stats.timestamp; + this.packetsSent = stats.packetsSent; + this.packetsReceived = stats.packetsReceived; + this.bytesSent = stats.bytesSent; + this.bytesReceived = stats.bytesReceived; + this.iceRole = stats.iceRole; + this.iceLocalUsernameFragment = stats.iceLocalUsernameFragment; + this.dtlsState = stats.dtlsState; + this.iceState = stats.iceState; + this.selectedCandidatePairId = stats.selectedCandidatePairId; + this.localCertificateId = stats.localCertificateId; + this.remoteCertificateId = stats.remoteCertificateId; + this.tlsVersion = stats.tlsVersion; + this.dtlsCipher = stats.dtlsCipher; + this.dtlsRole = stats.dtlsRole; + this.srtpCipher = stats.srtpCipher; + this.selectedCandidatePairChanges = stats.selectedCandidatePairChanges; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedInboundAudioTrack.ts b/src/ObservedInboundAudioTrack.ts deleted file mode 100644 index 29f7d6c..0000000 --- a/src/ObservedInboundAudioTrack.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { InboundAudioTrack } from '@observertc/sample-schemas-js'; -import { InboundAudioTrackReport, InboundVideoTrackReport } from '@observertc/report-schemas-js'; -import { calculateBaseAudioScore, CalculatedScore } from './common/CalculatedScore'; -import { ClientIssue } from './monitors/CallSummary'; -import { ObservedOutboundAudioTrack } from './ObservedOutboundAudioTrack'; - -export type ObservedInboundAudioTrackModel = { - trackId: string; - sfuStreamId?: string; - sfuSinkId?: string; -} - -export type ObservedInboundAudioTrackEvents = { - update: [{ - elapsedTimeInMs: number; - }], - score: [CalculatedScore], - close: [], - remoteoutboundtrack: [ObservedOutboundAudioTrack | undefined], -}; - -export type ObservedInboundAudioTrackStats = InboundAudioTrack & { - ssrc: number; - bitrate: number; - fractionLost: number; - rttInMs?: number; - deltaReceivedBytes?: number; - deltaLostPackets?: number; - deltaReceivedPackets?: number; - deltaReceivedFrames?: number; - deltaDecodedFrames?: number; - deltaDroppedFrames?: number; - deltaReceivedSamples?: number; - deltaSilentConcealedSamples?: number; - fractionLoss?: number; - statsTimestamp: number; -}; - -// { -// [Property in keyof ObservedInboundAudioTrackStats]: ObservedInboundAudioTrackStats[Property]; -// } - -export declare interface ObservedInboundAudioTrack { - on(event: U, listener: (...args: ObservedInboundAudioTrackEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedInboundAudioTrackEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedInboundAudioTrackEvents[U]) => void): this; - emit(event: U, ...args: ObservedInboundAudioTrackEvents[U]): boolean; - update(sample: InboundAudioTrack, timestamp: number): void; -} - -export class ObservedInboundAudioTrack extends EventEmitter { - public readonly created = Date.now(); - public visited = false; - - // timestamp of the MEDIA_TRACK_ADDED event - public added?: number; - // timestamp of the MEDIA_TRACK_REMOVED event - public removed?: number; - - private readonly _stats = new Map(); - - private _closed = false; - private _updated = Date.now(); - private _remoteOutboundTrack?: ObservedOutboundAudioTrack; - private _lastMaxStatsTimestamp = 0; - - public bitrate = 0; - public rttInMs?: number; - public jitter?: number; - public fractionLoss = 0; - public marker?: string; - - public totalLostPackets = 0; - public totalReceivedPackets = 0; - public totalBytesReceived = 0; - public totalReceivedSamples = 0; - public totalSilentConcealedSamples = 0; - - public deltaLostPackets = 0; - public deltaReceivedPackets = 0; - public deltaBytesReceived = 0; - public deltaReceivedSamples = 0; - public deltaSilentConcealedSamples = 0; - - public score?: CalculatedScore; - public ωpendingIssuesForScores: ClientIssue[] = []; - - public constructor( - private readonly _model: ObservedInboundAudioTrackModel, - public readonly peerConnection: ObservedPeerConnection, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.peerConnection.serviceId; - } - - public get roomId() { - return this.peerConnection.roomId; - } - - public get callId() { - return this.peerConnection.callId; - } - - public get clientId() { - return this.peerConnection.clientId; - } - - public get mediaUnitId() { - return this.peerConnection.mediaUnitId; - } - - public get peerConnectionId() { - return this.peerConnection.peerConnectionId; - } - - public get trackId() { - return this._model.trackId; - } - - public get sfuStreamId() { - return this._model.sfuStreamId; - } - - public get sfuSinkId() { - return this._model.sfuSinkId; - } - - public get updated() { - return this._updated; - } - - public get statsTimestamp() { - return this._lastMaxStatsTimestamp; - } - - public get stats(): ReadonlyMap { - return this._stats; - } - - public get reports() { - return this.peerConnection.reports; - } - - public set remoteOutboundTrack(track: ObservedOutboundAudioTrack | undefined) { - if (this._closed) return; - if (this._remoteOutboundTrack) return; - if (!track) return; - - track.once('close', () => { - this._remoteOutboundTrack = undefined; - this.emit('remoteoutboundtrack', undefined); - }); - this._remoteOutboundTrack = track; - this.emit('remoteoutboundtrack', track); - } - - public get remoteOutboundTrack() { - return this._remoteOutboundTrack; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - - this._closed = true; - - this.emit('close'); - } - - public update(sample: InboundAudioTrack, statsTimestamp: number) { - if (this._closed) return; - - const now = Date.now(); - const report: InboundAudioTrackReport | InboundVideoTrackReport = { - serviceId: this.peerConnection.client.call.serviceId, - roomId: this.peerConnection.client.call.roomId, - callId: this.peerConnection.client.call.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - peerConnectionId: this.peerConnection.peerConnectionId, - ...sample, - timestamp: statsTimestamp, - sampleSeq: -1, - - remoteClientId: sample.remoteClientId ?? this.remoteOutboundTrack?.peerConnection.client.clientId, - remoteUserId: this.remoteOutboundTrack?.peerConnection.client.userId, - remoteTrackId: this.remoteOutboundTrack?.trackId, - remotePeerConnectionId: this.remoteOutboundTrack?.peerConnection.peerConnectionId, - marker: this.marker, - }; - - this.reports.addInboundAudioTrackReport(report); - - if (!this._model.sfuStreamId && sample.sfuStreamId) { - this._model.sfuStreamId = sample.sfuStreamId; - } - - if (!this._model.sfuSinkId && sample.sfuSinkId) { - this._model.sfuSinkId = sample.sfuSinkId; - } - - const elapsedTimeInMs = Math.max(1, now - this._updated); - const lastStat = this._stats.get(sample.ssrc); - const rttInMs = sample.roundTripTime ? sample.roundTripTime * 1000 : undefined; - let bitrate = 0; - let deltaReceivedBytes = 0; - let fractionLost = 0; - let deltaLostPackets = 0; - let deltaReceivedPackets = 0; - - if (lastStat?.bytesReceived && sample.bytesReceived && lastStat.bytesReceived < sample.bytesReceived) { - deltaReceivedBytes = sample.bytesReceived - lastStat.bytesReceived; - bitrate = deltaReceivedBytes / (elapsedTimeInMs / 1000); - } - if (lastStat?.packetsReceived && sample?.packetsReceived && lastStat.packetsReceived < sample.packetsReceived) { - deltaReceivedPackets = sample.packetsReceived - lastStat.packetsReceived; - } - if (lastStat?.packetsLost && sample.packetsLost && lastStat.packetsLost < sample.packetsLost) { - deltaLostPackets = sample.packetsLost - lastStat.packetsLost; - if (0 < deltaReceivedPackets) { - fractionLost = deltaLostPackets / (deltaReceivedPackets + deltaLostPackets); - } - } - - let deltaDecodedFrames: number | undefined; - let deltaDroppedFrames: number | undefined; - let deltaReceivedFrames: number | undefined; - let deltaSilentConcealedSamples: number | undefined; - const audioSample = sample as InboundAudioTrack; - const lastAudioStat = lastStat as InboundAudioTrack | undefined; - - if (audioSample.silentConcealedSamples && lastAudioStat?.silentConcealedSamples && lastAudioStat.silentConcealedSamples < audioSample.silentConcealedSamples) { - deltaSilentConcealedSamples = audioSample.silentConcealedSamples - lastAudioStat.silentConcealedSamples; - } - - const stats: ObservedInboundAudioTrackStats = { - ...sample, - fractionLost, - rttInMs, - bitrate, - ssrc: sample.ssrc, - deltaReceivedBytes, - deltaLostPackets, - deltaReceivedPackets, - deltaReceivedFrames, - deltaDecodedFrames, - deltaDroppedFrames, - deltaSilentConcealedSamples, - statsTimestamp, - }; - - this._stats.set(sample.ssrc, stats); - - this.visited = true; - // a peer connection is active if it has at least one active track - this.peerConnection.visited = true; - - this._updated = now; - - this.emit('update', { - elapsedTimeInMs, - }); - } - - public updateMetrics() { - let maxStatsTimestamp = 0; - let rttInMsSum = 0; - let jitterSum = 0; - let size = 0; - - this.bitrate = 0; - this.rttInMs = undefined; - this.deltaBytesReceived = 0; - this.deltaLostPackets = 0; - this.deltaReceivedSamples = 0; - this.deltaSilentConcealedSamples = 0; - this.fractionLoss = 0; - - for (const [ , stats ] of this._stats) { - if (stats.statsTimestamp <= this._lastMaxStatsTimestamp) continue; - - this.deltaBytesReceived += stats.deltaReceivedBytes ?? 0; - this.deltaLostPackets += stats.deltaLostPackets ?? 0; - this.deltaReceivedPackets += stats.deltaReceivedFrames ?? 0; - this.deltaReceivedSamples += stats.deltaReceivedSamples ?? 0; - this.deltaSilentConcealedSamples += stats.deltaSilentConcealedSamples ?? 0; - this.bitrate += stats.bitrate; - - maxStatsTimestamp = Math.max(maxStatsTimestamp, stats.statsTimestamp); - - rttInMsSum += stats.rttInMs ?? 0; - jitterSum += stats.jitter ?? 0; - ++size; - } - - this.totalBytesReceived += this.deltaBytesReceived; - this.totalLostPackets += this.deltaLostPackets; - this.totalReceivedPackets += this.deltaReceivedPackets; - this.totalReceivedSamples += this.deltaReceivedSamples; - this.totalSilentConcealedSamples += this.deltaSilentConcealedSamples; - - this.rttInMs = rttInMsSum / Math.max(size, 1); - this.jitter = jitterSum / Math.max(size, 1); - this.fractionLoss = 0 < this.deltaReceivedPackets && 0 < this.deltaLostPackets ? (this.deltaLostPackets / (this.deltaReceivedPackets + this.deltaLostPackets)) : 0; - - this._lastMaxStatsTimestamp = maxStatsTimestamp; - - this._updateQualityScore(maxStatsTimestamp); - } - - private _updateQualityScore(timestamp: number) { - const newIssues = this.ωpendingIssuesForScores; - const score = calculateBaseAudioScore(this, newIssues); - - if (0 < newIssues.length) { - this.ωpendingIssuesForScores = []; - } - - if (!score) return (this.score = undefined); - - if (0.05 < this.fractionLoss) { - score.score *= 0.5; - score.remarks.push({ - severity: 'major', - text: 'Fraction loss is too high', - }); - } - - score.timestamp = timestamp; - this.score = score; - - this.emit('score', this.score); - } -} diff --git a/src/ObservedInboundRtp.ts b/src/ObservedInboundRtp.ts new file mode 100644 index 0000000..7239c80 --- /dev/null +++ b/src/ObservedInboundRtp.ts @@ -0,0 +1,205 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { InboundRtpStats } from './schema/ClientSample'; + +export class ObservedInboundRtp implements InboundRtpStats { + public appData?: Record; + + private _visited = false; + + transportId?: string | undefined; + codecId?: string | undefined; + packetsReceived?: number | undefined; + packetsLost?: number | undefined; + mid?: string | undefined; + remoteId?: string | undefined; + framesDecoded?: number | undefined; + keyFramesDecoded?: number | undefined; + framesRendered?: number | undefined; + framesDropped?: number | undefined; + frameWidth?: number | undefined; + frameHeight?: number | undefined; + framesPerSecond?: number | undefined; + qpSum?: number | undefined; + totalDecodeTime?: number | undefined; + totalInterFrameDelay?: number | undefined; + totalSquaredInterFrameDelay?: number | undefined; + pauseCount?: number | undefined; + totalPausesDuration?: number | undefined; + freezeCount?: number | undefined; + totalFreezesDuration?: number | undefined; + lastPacketReceivedTimestamp?: number | undefined; + headerBytesReceived?: number | undefined; + packetsDiscarded?: number | undefined; + fecBytesReceived?: number | undefined; + fecPacketsReceived?: number | undefined; + fecPacketsDiscarded?: number | undefined; + bytesReceived?: number | undefined; + nackCount?: number | undefined; + firCount?: number | undefined; + pliCount?: number | undefined; + totalProcessingDelay?: number | undefined; + estimatedPlayoutTimestamp?: number | undefined; + jitterBufferDelay?: number | undefined; + jitterBufferTargetDelay?: number | undefined; + jitterBufferEmittedCount?: number | undefined; + jitterBufferMinimumDelay?: number | undefined; + totalSamplesReceived?: number | undefined; + concealedSamples?: number | undefined; + silentConcealedSamples?: number | undefined; + concealmentEvents?: number | undefined; + insertedSamplesForDeceleration?: number | undefined; + removedSamplesForAcceleration?: number | undefined; + audioLevel?: number | undefined; + totalAudioEnergy?: number | undefined; + totalSamplesDuration?: number | undefined; + framesReceived?: number | undefined; + decoderImplementation?: string | undefined; + playoutId?: string | undefined; + powerEfficientDecoder?: boolean | undefined; + framesAssembledFromMultiplePackets?: number | undefined; + totalAssemblyTime?: number | undefined; + retransmittedPacketsReceived?: number | undefined; + retransmittedBytesReceived?: number | undefined; + rtxSsrc?: number | undefined; + fecSsrc?: number | undefined; + totalCorruptionProbability?: number | undefined; + totalSquaredCorruptionProbability?: number | undefined; + corruptionMeasurements?: number | undefined; + attachments?: Record | undefined; + jitter?: number | undefined; + + public bitrate = 0; + public fractionLost?: number; + public bitPerPixel = 0; + + public deltaLostPackets = 0; + public deltaReceivedPackets = 0; + public deltaBytesReceived = 0; + public deltaReceivedSamples = 0; + public deltaSilentConcealedSamples = 0; + + public constructor( + public timestamp: number, + public id: string, + public ssrc: number, + public kind: MediaKind, + public trackIdentifier: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getRemoteOutboundRtp() { + return this._peerConnection.observedRemoteOutboundRtps.get(this.ssrc); + } + + public getIceTransport() { + return this._peerConnection.observedIceTransports.get(this.transportId ?? ''); + } + + public getCodec() { + return this._peerConnection.observedCodecs.get(this.codecId ?? ''); + } + + public getMediaPlayout() { + return this._peerConnection.observedMediaPlayouts.get(this.playoutId ?? ''); + } + + public getTrack() { + return this._peerConnection.observedInboundTracks.get(this.trackIdentifier); + } + + public update(stats: InboundRtpStats) { + this._visited = true; + this.deltaBytesReceived = 0; + this.deltaLostPackets = 0; + this.deltaReceivedPackets = 0; + this.deltaReceivedSamples = 0; + this.deltaSilentConcealedSamples = 0; + this.bitrate = 0; + this.jitter = undefined; + this.fractionLost = undefined; + this.bitPerPixel = 0; + + const elapsedTimeInMs = stats.timestamp - this.timestamp; + + if (elapsedTimeInMs) { + // update metrics here + if (this.bytesReceived && stats.bytesReceived && this.bytesReceived < stats.bytesReceived) { + this.bitrate = ((stats.bytesReceived - (this.bytesReceived ?? 0)) * 8) / elapsedTimeInMs; + } + if (this.packetsLost && stats.packetsLost && this.packetsLost < stats.packetsLost) { + this.deltaLostPackets = stats.packetsLost - this.packetsLost; + } + if (this.packetsReceived && stats.packetsReceived && this.packetsReceived < stats.packetsReceived) { + this.deltaReceivedPackets = stats.packetsReceived - this.packetsReceived; + } + if (this.totalSamplesReceived && stats.totalSamplesReceived && this.totalSamplesReceived < stats.totalSamplesReceived) { + this.deltaReceivedSamples = stats.totalSamplesReceived - this.totalSamplesReceived; + } + if (this.silentConcealedSamples && stats.silentConcealedSamples && this.silentConcealedSamples < stats.silentConcealedSamples) { + this.deltaSilentConcealedSamples = stats.silentConcealedSamples - this.silentConcealedSamples; + } + if (stats.bytesReceived && this.framesReceived && stats.framesReceived && this.framesReceived < stats.framesReceived) { + this.bitPerPixel = (stats.bytesReceived - (this.bytesReceived ?? 0)) / (stats.framesReceived - this.framesReceived); + } + if (this.deltaLostPackets && this.deltaReceivedPackets) { + this.fractionLost = this.deltaLostPackets / (this.deltaLostPackets + this.deltaReceivedPackets); + } + } + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.codecId = stats.codecId; + this.packetsReceived = stats.packetsReceived; + this.packetsLost = stats.packetsLost; + this.mid = stats.mid; + this.remoteId = stats.remoteId; + this.framesDecoded = stats.framesDecoded; + this.keyFramesDecoded = stats.keyFramesDecoded; + this.framesRendered = stats.framesRendered; + this.framesDropped = stats.framesDropped; + this.frameWidth = stats.frameWidth; + this.frameHeight = stats.frameHeight; + this.framesPerSecond = stats.framesPerSecond; + this.qpSum = stats.qpSum; + this.totalDecodeTime = stats.totalDecodeTime; + this.totalInterFrameDelay = stats.totalInterFrameDelay; + this.totalSquaredInterFrameDelay = stats.totalSquaredInterFrameDelay; + this.pauseCount = stats.pauseCount; + this.totalPausesDuration = stats.totalPausesDuration; + this.freezeCount = stats.freezeCount; + this.totalFreezesDuration = stats.totalFreezesDuration; + this.lastPacketReceivedTimestamp = stats.lastPacketReceivedTimestamp; + this.headerBytesReceived = stats.headerBytesReceived; + this.packetsDiscarded = stats.packetsDiscarded; + this.fecBytesReceived = stats.fecBytesReceived; + this.fecPacketsReceived = stats.fecPacketsReceived; + this.fecPacketsDiscarded = stats.fecPacketsDiscarded; + this.bytesReceived = stats.bytesReceived; + this.nackCount = stats.nackCount; + this.firCount = stats.firCount; + this.pliCount = stats.pliCount; + this.totalProcessingDelay = stats.totalProcessingDelay; + this.estimatedPlayoutTimestamp = stats.estimatedPlayoutTimestamp; + this.jitterBufferDelay = stats.jitterBufferDelay; + this.jitterBufferTargetDelay = stats.jitterBufferTargetDelay; + this.jitterBufferEmittedCount = stats.jitterBufferEmittedCount; + this.jitterBufferMinimumDelay = stats.jitterBufferMinimumDelay; + this.totalSamplesReceived = stats.totalSamplesReceived; + this.concealedSamples = stats.concealedSamples; + this.silentConcealedSamples = stats.silentConcealedSamples; + this.concealmentEvents = stats.concealmentEvents; + } +} diff --git a/src/ObservedInboundTrack.ts b/src/ObservedInboundTrack.ts new file mode 100644 index 0000000..2f883b9 --- /dev/null +++ b/src/ObservedInboundTrack.ts @@ -0,0 +1,75 @@ +import { CalculatedScore } from './scores/CalculatedScore'; +import { MediaKind } from './common/types'; +import { InboundTrackSample } from './schema/ClientSample'; +import { Detectors } from './detectors/Detectors'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ObservedInboundRtp } from './ObservedInboundRtp'; +import { ObservedMediaPlayout } from './ObservedMediaPlayout'; + +export class ObservedInboundTrack implements InboundTrackSample { + public readonly detectors: Detectors; + public readonly calculatedScore: CalculatedScore = { + weight: 1, + value: undefined, + }; + public appData?: Record; + + private _visited = false; + + public addedAt?: number | undefined; + public removedAt?: number | undefined; + + public muted?: boolean; + + attachments?: Record | undefined; + + constructor( + public timestamp: number, + public readonly id: string, + public readonly kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection, + private readonly _inboundRtp?: ObservedInboundRtp, + private readonly _mediaPlayout?: ObservedMediaPlayout, + ) { + this.detectors = new Detectors(); + } + + public get score() { + return this.calculatedScore.value; + } + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getInboundRtp() { + return this._inboundRtp; + } + + public getMediaPlayout() { + return this._mediaPlayout; + } + + public getRemoteOutboundTrack() { + return this._peerConnection.client.call.remoteTrackResolver?.resolveRemoteOutboundTrack(this); + } + + public update(stats: InboundTrackSample): void { + this._visited = true; + + this.timestamp = stats.timestamp; + this.calculatedScore.value = stats.score; + this.attachments = stats.attachments; + + this.detectors.update(); + } + +} \ No newline at end of file diff --git a/src/ObservedInboundVideoTrack.ts b/src/ObservedInboundVideoTrack.ts deleted file mode 100644 index c7fb90d..0000000 --- a/src/ObservedInboundVideoTrack.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { InboundVideoTrack } from '@observertc/sample-schemas-js'; -import { InboundAudioTrackReport, InboundVideoTrackReport } from '@observertc/report-schemas-js'; -import { CalculatedScore, calculateBaseVideoScore } from './common/CalculatedScore'; -import { ClientIssue } from './monitors/CallSummary'; -import { ObservedOutboundVideoTrack } from './ObservedOutboundVideoTrack'; -import { SupportedVideoCodecType } from './common/types'; - -export type ObservedInboundVideoTrackModel = { - trackId: string; - sfuStreamId?: string; - sfuSinkId?: string; -} - -export type ObservedInboundVideoTrackEvents = { - update: [{ - elapsedTimeInMs: number; - }], - score: [CalculatedScore], - close: [], - remoteoutboundtrack: [ObservedOutboundVideoTrack | undefined], -}; - -export type ObservedInboundVideoTrackStatsUpdate = InboundVideoTrack; - -export type ObservedInboundVideoTrackStats = InboundVideoTrack & { - ssrc: number; - bitrate: number; - fractionLost: number; - rttInMs?: number; - deltaReceivedBytes?: number; - deltaLostPackets?: number; - deltaReceivedPackets?: number; - deltaReceivedFrames?: number; - deltaDecodedFrames?: number; - deltaDroppedFrames?: number; - fractionLoss?: number; - statsTimestamp: number; -}; - -// { -// [Property in keyof ObservedInboundTrackStats]: ObservedInboundTrackStats[Property]; -// } - -export declare interface ObservedInboundVideoTrack { - on(event: U, listener: (...args: ObservedInboundVideoTrackEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedInboundVideoTrackEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedInboundVideoTrackEvents[U]) => void): this; - emit(event: U, ...args: ObservedInboundVideoTrackEvents[U]): boolean; - update(sample: InboundVideoTrack, timestamp: number): void; -} - -export class ObservedInboundVideoTrack extends EventEmitter { - public readonly created = Date.now(); - public visited = false; - - // timestamp of the MEDIA_TRACK_ADDED event - public added?: number; - // timestamp of the MEDIA_TRACK_REMOVED event - public removed?: number; - - public contentType: 'lowmotion' | 'standard' | 'highmotion' = 'standard'; - public codec?: SupportedVideoCodecType; - - private readonly _stats = new Map(); - - private _closed = false; - private _updated = Date.now(); - private _remoteOutboundTrack?: ObservedOutboundVideoTrack; - private _lastMaxStatsTimestamp = 0; - - public bitrate = 0; - public rttInMs?: number; - public jitter?: number; - public fractionLoss = 0; - public marker?: string; - - public totalLostPackets = 0; - public totalReceivedPackets = 0; - public totalBytesReceived = 0; - public totalReceivedFrames = 0; - public totalDecodedFrames = 0; - public totalDroppedFrames = 0; - - public deltaLostPackets = 0; - public deltaReceivedPackets = 0; - public deltaBytesReceived = 0; - public deltaReceivedFrames = 0; - public deltaDecodedFrames = 0; - public deltaDroppedFrames = 0; - public highestLayer?: ObservedInboundVideoTrackStats; - public score?: CalculatedScore; - - public ωpendingIssuesForScores: ClientIssue[] = []; - - public constructor( - private readonly _model: ObservedInboundVideoTrackModel, - public readonly peerConnection: ObservedPeerConnection, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.peerConnection.serviceId; - } - - public get roomId() { - return this.peerConnection.roomId; - } - - public get callId() { - return this.peerConnection.callId; - } - - public get clientId() { - return this.peerConnection.clientId; - } - - public get mediaUnitId() { - return this.peerConnection.mediaUnitId; - } - - public get peerConnectionId() { - return this.peerConnection.peerConnectionId; - } - - public get trackId() { - return this._model.trackId; - } - - public get sfuStreamId() { - return this._model.sfuStreamId; - } - - public get sfuSinkId() { - return this._model.sfuSinkId; - } - - public get updated() { - return this._updated; - } - - public get statsTimestamp() { - return this._lastMaxStatsTimestamp; - } - - public get stats(): ReadonlyMap { - return this._stats; - } - - public get reports() { - return this.peerConnection.reports; - } - - public set remoteOutboundTrack(track: ObservedOutboundVideoTrack | undefined) { - if (this._closed) return; - if (this._remoteOutboundTrack) return; - if (!track) return; - - track.once('close', () => { - this._remoteOutboundTrack = undefined; - this.emit('remoteoutboundtrack', undefined); - }); - this._remoteOutboundTrack = track; - this.emit('remoteoutboundtrack', track); - } - - public get remoteOutboundTrack() { - return this._remoteOutboundTrack; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - - this._closed = true; - - this.emit('close'); - } - - public update(sample: ObservedInboundVideoTrackStatsUpdate, statsTimestamp: number) { - if (this._closed) return; - - const now = Date.now(); - const report: InboundAudioTrackReport | InboundVideoTrackReport = { - serviceId: this.peerConnection.client.call.serviceId, - roomId: this.peerConnection.client.call.roomId, - callId: this.peerConnection.client.call.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - peerConnectionId: this.peerConnection.peerConnectionId, - ...sample, - timestamp: statsTimestamp, - sampleSeq: -1, - - remoteClientId: sample.remoteClientId ?? this.remoteOutboundTrack?.peerConnection.client.clientId, - remoteUserId: this.remoteOutboundTrack?.peerConnection.client.userId, - remoteTrackId: this.remoteOutboundTrack?.trackId, - remotePeerConnectionId: this.remoteOutboundTrack?.peerConnection.peerConnectionId, - marker: this.marker, - }; - - this.reports.addInboundVideoTrackReport(report); - - if (!this._model.sfuStreamId && sample.sfuStreamId) { - this._model.sfuStreamId = sample.sfuStreamId; - } - - if (!this._model.sfuSinkId && sample.sfuSinkId) { - this._model.sfuSinkId = sample.sfuSinkId; - } - - const elapsedTimeInMs = Math.max(1, now - this._updated); - const lastStat = this._stats.get(sample.ssrc); - const rttInMs = sample.roundTripTime ? sample.roundTripTime * 1000 : undefined; - let bitrate = 0; - let deltaReceivedBytes = 0; - let fractionLost = 0; - let deltaLostPackets = 0; - let deltaReceivedPackets = 0; - - if (lastStat?.bytesReceived && sample.bytesReceived && lastStat.bytesReceived < sample.bytesReceived) { - deltaReceivedBytes = sample.bytesReceived - lastStat.bytesReceived; - bitrate = deltaReceivedBytes / (elapsedTimeInMs / 1000); - } - if (lastStat?.packetsReceived && sample?.packetsReceived && lastStat.packetsReceived < sample.packetsReceived) { - deltaReceivedPackets = sample.packetsReceived - lastStat.packetsReceived; - } - if (lastStat?.packetsLost && sample.packetsLost && lastStat.packetsLost < sample.packetsLost) { - deltaLostPackets = sample.packetsLost - lastStat.packetsLost; - if (0 < deltaReceivedPackets) { - fractionLost = deltaLostPackets / (deltaReceivedPackets + deltaLostPackets); - } - } - - let deltaDecodedFrames: number | undefined; - let deltaDroppedFrames: number | undefined; - let deltaReceivedFrames: number | undefined; - const videoSample = sample as InboundVideoTrack; - const lastVideoStat = lastStat as InboundVideoTrack | undefined; - - if (videoSample.framesDecoded && lastVideoStat?.framesDecoded && lastVideoStat.framesDecoded < videoSample.framesDecoded) { - deltaDecodedFrames = videoSample.framesDecoded - lastVideoStat.framesDecoded; - } - if (videoSample.framesDropped && lastVideoStat?.framesDropped && lastVideoStat.framesDropped < videoSample.framesDropped) { - deltaDroppedFrames = videoSample.framesDropped - lastVideoStat.framesDropped; - } - if (videoSample.framesReceived && lastVideoStat?.framesReceived && lastVideoStat.framesReceived < videoSample.framesReceived) { - deltaReceivedFrames = videoSample.framesReceived - lastVideoStat.framesReceived; - } - - const stats: ObservedInboundVideoTrackStats = { - ...sample, - fractionLost, - rttInMs, - bitrate, - ssrc: sample.ssrc, - deltaReceivedBytes, - deltaLostPackets, - deltaReceivedPackets, - deltaReceivedFrames, - deltaDecodedFrames, - deltaDroppedFrames, - statsTimestamp, - }; - - this._stats.set(sample.ssrc, stats); - - this.visited = true; - // a peer connection is active if it has at least one active track - this.peerConnection.visited = true; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } - - public updateMetrics() { - let maxStatsTimestamp = 0; - let rttInMsSum = 0; - let jitterSum = 0; - let size = 0; - - this.bitrate = 0; - this.rttInMs = undefined; - this.deltaBytesReceived = 0; - this.deltaLostPackets = 0; - this.deltaReceivedFrames = 0; - this.deltaDecodedFrames = 0; - this.deltaDroppedFrames = 0; - this.fractionLoss = 0; - - const highestLayer = { ssrc: -1, bitrate: -1 }; - - for (const [ ssrc, stats ] of this._stats) { - if (stats.statsTimestamp <= this._lastMaxStatsTimestamp) continue; - - this.deltaBytesReceived += stats.deltaReceivedBytes ?? 0; - this.deltaLostPackets += stats.deltaLostPackets ?? 0; - this.deltaReceivedPackets += stats.deltaReceivedFrames ?? 0; - this.deltaReceivedFrames += stats.deltaReceivedFrames ?? 0; - this.deltaDecodedFrames += stats.deltaDecodedFrames ?? 0; - this.deltaDroppedFrames += stats.deltaDroppedFrames ?? 0; - this.bitrate += stats.bitrate; - - maxStatsTimestamp = Math.max(maxStatsTimestamp, stats.statsTimestamp); - - if (highestLayer.bitrate < stats.bitrate) { - highestLayer.ssrc = ssrc; - } - rttInMsSum += stats.rttInMs ?? 0; - jitterSum += stats.jitter ?? 0; - ++size; - } - this.highestLayer = this.stats.get(highestLayer.ssrc); - this.totalBytesReceived += this.deltaBytesReceived; - this.totalLostPackets += this.deltaLostPackets; - this.totalReceivedPackets += this.deltaReceivedPackets; - this.totalReceivedFrames += this.deltaReceivedFrames; - this.totalDecodedFrames += this.deltaDecodedFrames; - this.totalDroppedFrames += this.deltaDroppedFrames; - - this.rttInMs = rttInMsSum / Math.max(size, 1); - this.jitter = jitterSum / Math.max(size, 1); - this.fractionLoss = 0 < this.deltaReceivedPackets && 0 < this.deltaLostPackets ? (this.deltaLostPackets / (this.deltaReceivedPackets + this.deltaLostPackets)) : 0; - - this._lastMaxStatsTimestamp = maxStatsTimestamp; - this._updateQualityScore(maxStatsTimestamp); - } - - private _updateQualityScore(timestamp: number) { - const newIssues = this.ωpendingIssuesForScores; - const score = calculateBaseVideoScore(this, newIssues); - - if (0 < this.ωpendingIssuesForScores.length) { - this.ωpendingIssuesForScores = []; - } - - if (!score) return (this.score = undefined); - - if (0.05 < this.fractionLoss) { - score.score *= 0.5; - score.remarks.push({ - severity: 'major', - text: 'Fraction loss is too high', - }); - } - - score.timestamp = timestamp; - this.score = score; - - this.emit('score', this.score); - } -} diff --git a/src/ObservedMediaPlayout.ts b/src/ObservedMediaPlayout.ts new file mode 100644 index 0000000..88cd7da --- /dev/null +++ b/src/ObservedMediaPlayout.ts @@ -0,0 +1,49 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { MediaPlayoutStats } from './schema/ClientSample'; + +export class ObservedMediaPlayout implements MediaPlayoutStats { + private _visited = false; + public appData?: Record; + + synthesizedSamplesDuration?: number | undefined; + synthesizedSamplesEvents?: number | undefined; + totalSamplesDuration?: number | undefined; + totalPlayoutDelay?: number | undefined; + totalSamplesCount?: number | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + public kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + + + public update(stats: MediaPlayoutStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.synthesizedSamplesDuration = stats.synthesizedSamplesDuration; + this.synthesizedSamplesEvents = stats.synthesizedSamplesEvents; + this.totalSamplesDuration = stats.totalSamplesDuration; + this.totalPlayoutDelay = stats.totalPlayoutDelay; + this.totalSamplesCount = stats.totalSamplesCount; + this.attachments = stats.attachments; + + } +} diff --git a/src/ObservedMediaSource.ts b/src/ObservedMediaSource.ts new file mode 100644 index 0000000..59c861f --- /dev/null +++ b/src/ObservedMediaSource.ts @@ -0,0 +1,60 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { MediaSourceStats } from './schema/ClientSample'; + +export class ObservedMediaSource implements MediaSourceStats { + private _visited = false; + public appData?: Record; + + trackIdentifier?: string | undefined; + audioLevel?: number | undefined; + totalAudioEnergy?: number | undefined; + totalSamplesDuration?: number | undefined; + echoReturnLoss?: number | undefined; + echoReturnLossEnhancement?: number | undefined; + width?: number | undefined; + height?: number | undefined; + frames?: number | undefined; + framesPerSecond?: number | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + public kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getTrack() { + return this._peerConnection.observedOutboundTracks.get(this.trackIdentifier ?? ''); + } + + public update(stats: MediaSourceStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.trackIdentifier = stats.trackIdentifier; + this.audioLevel = stats.audioLevel; + this.totalAudioEnergy = stats.totalAudioEnergy; + this.totalSamplesDuration = stats.totalSamplesDuration; + this.echoReturnLoss = stats.echoReturnLoss; + this.echoReturnLossEnhancement = stats.echoReturnLossEnhancement; + this.width = stats.width; + this.height = stats.height; + this.frames = stats.frames; + this.framesPerSecond = stats.framesPerSecond; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedOutboundAudioTrack.ts b/src/ObservedOutboundAudioTrack.ts deleted file mode 100644 index 227ee65..0000000 --- a/src/ObservedOutboundAudioTrack.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { OutboundAudioTrack, OutboundVideoTrack } from '@observertc/sample-schemas-js'; -import { OutboundAudioTrackReport, OutboundVideoTrackReport } from '@observertc/report-schemas-js'; -import { ObservedInboundAudioTrack } from './ObservedInboundAudioTrack'; -import { calculateBaseAudioScore, CalculatedScore } from './common/CalculatedScore'; -import { ClientIssue } from './monitors/CallSummary'; - -export type ObservedOutboundAudioTrackModel = { - trackId: string; - sfuStreamId?: string; -} - -export type ObservedOutboundAudioTrackEvents = { - qualitylimitationchanged: [string]; - update: [{ - elapsedTimeInMs: number; - }], - score: [CalculatedScore], - close: [], -}; - -export type ObservedOutboundAudioTrackStats = OutboundAudioTrack & { - ssrc: number; - bitrate: number; - rttInMs?: number; - - deltaLostPackets: number; - deltaSentPackets: number; - deltaSentBytes: number; - deltaSentFrames?: number; - deltaEncodedFrames?: number; - - statsTimestamp: number; -}; - -// export type ObservedOutboundTrackStatsUpdate = { -// [Property in keyof ObservedOutboundTrackStats]: ObservedOutboundTrackStats[Property]; -// } - -export declare interface ObservedOutboundAudioTrack { - on(event: U, listener: (...args: ObservedOutboundAudioTrackEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedOutboundAudioTrackEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedOutboundAudioTrackEvents[U]) => void): this; - emit(event: U, ...args: ObservedOutboundAudioTrackEvents[U]): boolean; - update(sample: OutboundAudioTrack, timestamp: number): void; -} - -export class ObservedOutboundAudioTrack extends EventEmitter { - public readonly created = Date.now(); - public visited = false; - - // timestamp of the MEDIA_TRACK_ADDED event - public added?: number; - // timestamp of the MEDIA_TRACK_REMOVED event - public removed?: number; - - public bitrate = 0; - public rttInMs?: number; - public jitter?: number; - public marker?: string; - - public totalLostPackets = 0; - public totalSentPackets = 0; - public totalSentBytes = 0; - public totalSentFrames = 0; - - public deltaLostPackets = 0; - public deltaSentPackets = 0; - public deltaSentBytes = 0; - public deltaSentFrames = 0; - public deltaEncodedFrames = 0; - - public sendingBitrate = 0; - - private readonly _stats = new Map(); - private _lastMaxStatsTimestamp = 0; - - private _closed = false; - private _updated = Date.now(); - private _lastUpdateMetrics?: number; - - public score?: CalculatedScore; - public ωpendingIssuesForScores: ClientIssue[] = []; - - public readonly remoteInboundTracks = new Map(); - - public constructor( - private readonly _model: ObservedOutboundAudioTrackModel, - public readonly peerConnection: ObservedPeerConnection, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.peerConnection.serviceId; - } - - public get roomId() { - return this.peerConnection.roomId; - } - - public get callId() { - return this.peerConnection.callId; - } - - public get clientId() { - return this.peerConnection.clientId; - } - - public get mediaUnitId() { - return this.peerConnection.mediaUnitId; - } - - public get peerConnectionId() { - return this.peerConnection.peerConnectionId; - } - - public get trackId() { - return this._model.trackId; - } - - public get sfuStreamId() { - return this._model.sfuStreamId; - } - - public get reports() { - return this.peerConnection.reports; - } - - public get statsTimestamp() { - return this._lastMaxStatsTimestamp; - } - - public get updated() { - return this._updated; - } - - public get stats(): ReadonlyMap { - return this._stats; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - this.emit('close'); - } - - public update(sample: OutboundAudioTrack, statsTimestamp: number): void { - if (this._closed) return; - - const now = Date.now(); - const report: OutboundAudioTrackReport | OutboundVideoTrackReport = { - serviceId: this.peerConnection.client.serviceId, - roomId: this.peerConnection.client.call.roomId, - callId: this.peerConnection.client.call.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - peerConnectionId: this.peerConnection.peerConnectionId, - ...sample, - timestamp: statsTimestamp, - sampleSeq: -1, - marker: this.marker, - }; - - this.reports.addOutboundAudioTrackReport(report); - - const elapsedTimeInMs = Math.max(1, now - this._updated); - const lastStat = this._stats.get(sample.ssrc); - const rttInMs = sample.roundTripTime ? sample.roundTripTime * 1000 : undefined; - let bitrate = 0; - let deltaLostPackets = 0; - let deltaSentPackets = 0; - let deltaSentBytes = 0; - - if (sample.bytesSent && lastStat?.bytesSent && lastStat.bytesSent < sample.bytesSent) { - bitrate = (sample.bytesSent - lastStat.bytesSent) * 8 / (elapsedTimeInMs / 1000); - } - if (sample.packetsLost && lastStat?.packetsLost && lastStat.packetsLost < sample.packetsLost) { - deltaLostPackets = sample.packetsLost - lastStat.packetsLost; - } - if (sample.packetsSent && lastStat?.packetsSent && lastStat.packetsSent < sample.packetsSent) { - deltaSentPackets = sample.packetsSent - lastStat.packetsSent; - } - if (sample.bytesSent && lastStat?.bytesSent && lastStat.bytesSent < sample.bytesSent) { - deltaSentBytes = sample.bytesSent - lastStat.bytesSent; - } - - let deltaEncodedFrames: number | undefined; - let deltaSentFrames: number | undefined; - - const videoSample = sample as OutboundVideoTrack; - const lastVideoStats = lastStat as OutboundVideoTrack | undefined; - - if (videoSample?.framesEncoded && lastVideoStats?.framesEncoded && lastVideoStats.framesEncoded < videoSample.framesEncoded) { - deltaEncodedFrames = videoSample.framesEncoded - lastVideoStats.framesEncoded; - } - if (videoSample?.framesSent && lastVideoStats?.framesSent && lastVideoStats.framesSent < videoSample.framesSent) { - deltaSentFrames = videoSample.framesSent - lastVideoStats.framesSent; - } - - if (videoSample.qualityLimitationReason && lastVideoStats?.qualityLimitationReason !== videoSample.qualityLimitationReason) { - this.emit('qualitylimitationchanged', videoSample.qualityLimitationReason); - } - - const stats: ObservedOutboundAudioTrackStats = { - ...sample, - rttInMs, - bitrate, - ssrc: sample.ssrc, - - deltaLostPackets, - deltaSentPackets, - deltaSentBytes, - deltaEncodedFrames, - deltaSentFrames, - - statsTimestamp, - }; - - this._stats.set(sample.ssrc, stats); - - this.visited = true; - // a peer connection is active if it has at least one active track - this.peerConnection.visited = true; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } - - public updateMetrics() { - let maxStatsTimestamp = 0; - let rttInMsSum = 0; - let jitterSum = 0; - let size = 0; - - this.bitrate = 0; - this.rttInMs = undefined; - - this.sendingBitrate = 0; - this.deltaLostPackets = 0; - this.deltaSentPackets = 0; - this.deltaSentBytes = 0; - this.deltaSentFrames = 0; - this.deltaEncodedFrames = 0; - - for (const [ , stats ] of this._stats) { - if (stats.statsTimestamp <= this._lastMaxStatsTimestamp) continue; - - this.deltaLostPackets += stats.deltaLostPackets ?? 0; - this.deltaSentPackets += stats.deltaSentPackets ?? 0; - this.deltaSentBytes += stats.deltaSentBytes ?? 0; - this.deltaSentFrames += stats.deltaSentFrames ?? 0; - this.deltaEncodedFrames += stats.deltaEncodedFrames ?? 0; - this.bitrate += stats.bitrate; - - maxStatsTimestamp = Math.max(maxStatsTimestamp, stats.statsTimestamp); - - rttInMsSum += stats.rttInMs ?? 0; - jitterSum += stats.jitter ?? 0; - ++size; - } - - const now = Date.now(); - - if (this._lastUpdateMetrics) { - this.sendingBitrate = (this.deltaSentBytes * 8) / ((now - this._lastUpdateMetrics) / 1000); - } - this._lastUpdateMetrics = now; - - this.totalLostPackets += this.deltaLostPackets; - this.totalSentPackets += this.deltaSentPackets; - this.totalSentBytes += this.deltaSentBytes; - this.totalSentFrames += this.deltaSentFrames; - - this.rttInMs = rttInMsSum / Math.max(size, 1); - this.jitter = jitterSum / Math.max(size, 1); - - this._lastMaxStatsTimestamp = maxStatsTimestamp; - this._updateQualityScore(maxStatsTimestamp); - } - - private _updateQualityScore(timestamp: number) { - const newIssues = this.ωpendingIssuesForScores; - const score = calculateBaseAudioScore(this, newIssues); - - if (0 < newIssues.length) { - this.ωpendingIssuesForScores = []; - } - - if (!score) return (this.score = undefined); - - score.timestamp = timestamp; - this.score = score; - - this.emit('score', this.score); - } -} diff --git a/src/ObservedOutboundRtp.ts b/src/ObservedOutboundRtp.ts new file mode 100644 index 0000000..ac72bec --- /dev/null +++ b/src/ObservedOutboundRtp.ts @@ -0,0 +1,156 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { OutboundRtpStats, QualityLimitationDurations } from './schema/ClientSample'; + +export class ObservedOutboundRtp implements OutboundRtpStats { + private _visited = false; + public appData?: Record; + + transportId?: string | undefined; + codecId?: string | undefined; + packetsSent?: number | undefined; + bytesSent?: number | undefined; + mid?: string | undefined; + mediaSourceId?: string | undefined; + remoteId?: string | undefined; + rid?: string | undefined; + headerBytesSent?: number | undefined; + retransmittedPacketsSent?: number | undefined; + retransmittedBytesSent?: number | undefined; + rtxSsrc?: number | undefined; + targetBitrate?: number | undefined; + totalEncodedBytesTarget?: number | undefined; + frameWidth?: number | undefined; + frameHeight?: number | undefined; + framesPerSecond?: number | undefined; + framesSent?: number | undefined; + hugeFramesSent?: number | undefined; + framesEncoded?: number | undefined; + keyFramesEncoded?: number | undefined; + qpSum?: number | undefined; + totalEncodeTime?: number | undefined; + totalPacketSendDelay?: number | undefined; + qualityLimitationReason?: string | undefined; + qualityLimitationResolutionChanges?: number | undefined; + nackCount?: number | undefined; + firCount?: number | undefined; + pliCount?: number | undefined; + encoderImplementation?: string | undefined; + powerEfficientEncoder?: boolean | undefined; + active?: boolean | undefined; + scalabilityMode?: string | undefined; + qualityLimitationDurations?: QualityLimitationDurations | undefined; + attachments?: Record | undefined; + + // derived fields + public bitrate = 0; + public payloadBitrate = 0; + public packetRate = 0; + public bitPerPixel = 0; + + public deltaPacketsSent = 0; + public deltaBytesSent = 0; + + public constructor( + public timestamp: number, + public id: string, + public ssrc: number, + public kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection, + ) { + } + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getRemoteInboundRtp() { + return this._peerConnection.observedRemoteInboundRtps.get(this.ssrc); + } + + public getCodec() { + return this._peerConnection.observedCodecs.get(this.codecId ?? ''); + } + + public getMediaSource() { + return this._peerConnection.observedMediaSources.get(this.mediaSourceId ?? ''); + } + + public getTrack() { + return this.getMediaSource()?.getTrack(); + } + + public update(stats: OutboundRtpStats) { + this._visited = true; + this.bitPerPixel = 0; + this.bitrate = 0; + this.payloadBitrate = 0; + this.packetRate = 0; + this.deltaPacketsSent = 0; + this.deltaBytesSent = 0; + + const elapsedTimeInMs = stats.timestamp - this.timestamp; + + if (elapsedTimeInMs) { + if (stats.packetsSent !== undefined && this.packetsSent !== undefined) { + this.deltaPacketsSent = stats.packetsSent - this.packetsSent; + this.packetRate = this.deltaPacketsSent / (elapsedTimeInMs / 1000); + } + if (stats.bytesSent !== undefined && this.bytesSent !== undefined) { + this.deltaBytesSent = stats.bytesSent - this.bytesSent; + this.bitrate = (this.deltaBytesSent * 8) / (elapsedTimeInMs / 1000); + } + if (stats.headerBytesSent !== undefined && this.headerBytesSent !== undefined) { + this.payloadBitrate = ((this.deltaBytesSent ?? 0 - (stats.headerBytesSent - this.headerBytesSent)) * 8) / (elapsedTimeInMs / 1000); + } + if (this.framesSent !== undefined && stats.framesSent !== undefined) { + this.bitPerPixel = this.deltaBytesSent ? (this.deltaBytesSent * 8) / (this.framesSent - stats.framesSent) : 0; + } + } + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.codecId = stats.codecId; + this.packetsSent = stats.packetsSent; + this.bytesSent = stats.bytesSent; + this.mid = stats.mid; + this.mediaSourceId = stats.mediaSourceId; + this.remoteId = stats.remoteId; + this.rid = stats.rid; + this.headerBytesSent = stats.headerBytesSent; + this.retransmittedPacketsSent = stats.retransmittedPacketsSent; + this.retransmittedBytesSent = stats.retransmittedBytesSent; + this.rtxSsrc = stats.rtxSsrc; + this.targetBitrate = stats.targetBitrate; + this.totalEncodedBytesTarget = stats.totalEncodedBytesTarget; + this.frameWidth = stats.frameWidth; + this.frameHeight = stats.frameHeight; + this.framesPerSecond = stats.framesPerSecond; + this.framesSent = stats.framesSent; + this.hugeFramesSent = stats.hugeFramesSent; + this.framesEncoded = stats.framesEncoded; + this.keyFramesEncoded = stats.keyFramesEncoded; + this.qpSum = stats.qpSum; + this.totalEncodeTime = stats.totalEncodeTime; + this.totalPacketSendDelay = stats.totalPacketSendDelay; + this.qualityLimitationReason = stats.qualityLimitationReason; + this.qualityLimitationResolutionChanges = stats.qualityLimitationResolutionChanges; + this.nackCount = stats.nackCount; + this.firCount = stats.firCount; + this.pliCount = stats.pliCount; + this.encoderImplementation = stats.encoderImplementation; + this.powerEfficientEncoder = stats.powerEfficientEncoder; + this.active = stats.active; + this.scalabilityMode = stats.scalabilityMode; + this.qualityLimitationDurations = stats.qualityLimitationDurations; + this.attachments = stats.attachments; + } +} \ No newline at end of file diff --git a/src/ObservedOutboundTrack.ts b/src/ObservedOutboundTrack.ts new file mode 100644 index 0000000..a841345 --- /dev/null +++ b/src/ObservedOutboundTrack.ts @@ -0,0 +1,74 @@ +import { CalculatedScore } from './scores/CalculatedScore'; +import { MediaKind } from './common/types'; +import { OutboundTrackSample } from './schema/ClientSample'; +import { Detectors } from './detectors/Detectors'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ObservedOutboundRtp } from './ObservedOutboundRtp'; +import { ObservedMediaSource } from './ObservedMediaSource'; + +export class ObservedOutboundTrack implements OutboundTrackSample { + public readonly detectors: Detectors; + private _visited = false; + public appData?: Record; + public readonly calculatedScore: CalculatedScore = { + weight: 1, + value: undefined, + }; + + public addedAt?: number | undefined; + public removedAt?: number | undefined; + + public muted?: boolean; + + attachments?: Record | undefined; + + constructor( + public timestamp: number, + public readonly id: string, + public readonly kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection, + private readonly _outboundRtps?: ObservedOutboundRtp[], + private readonly _mediaSource?: ObservedMediaSource, + ) { + this.detectors = new Detectors(); + } + + public get score() { + return this.calculatedScore.value; + } + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getOutboundRtps() { + return this._outboundRtps; + } + + public getMediaSource() { + return this._mediaSource; + } + + public getRemoteInboundTracks() { + return this._peerConnection.client.call.remoteTrackResolver?.resolveRemoteInboundTracks(this); + } + + public update(stats: OutboundTrackSample): void { + this._visited = true; + + this.timestamp = stats.timestamp; + this.calculatedScore.value = stats.score; + this.attachments = stats.attachments; + + this.detectors.update(); + } + +} \ No newline at end of file diff --git a/src/ObservedOutboundVideoTrack.ts b/src/ObservedOutboundVideoTrack.ts deleted file mode 100644 index 1494e8f..0000000 --- a/src/ObservedOutboundVideoTrack.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { OutboundVideoTrack } from '@observertc/sample-schemas-js'; -import { OutboundAudioTrackReport, OutboundVideoTrackReport } from '@observertc/report-schemas-js'; -import { ObservedInboundVideoTrack } from './ObservedInboundVideoTrack'; -import { calculateBaseVideoScore, CalculatedScore } from './common/CalculatedScore'; -import { ClientIssue } from './monitors/CallSummary'; -import { SupportedVideoCodecType } from './common/types'; - -export type ObservedOutboundVideoTrackModel = { - trackId: string; - sfuStreamId?: string; -} - -export type ObservedOutboundVideoTrackEvents = { - qualitylimitationchanged: [string]; - update: [{ - elapsedTimeInMs: number; - }], - score: [CalculatedScore], - close: [], -}; - -export type ObservedOutboundVideoTrackStats = OutboundVideoTrack & { - ssrc: number; - bitrate: number; - rttInMs?: number; - - deltaLostPackets: number; - deltaSentPackets: number; - deltaSentBytes: number; - deltaSentFrames?: number; - deltaEncodedFrames?: number; - - statsTimestamp: number; -}; - -// export type ObservedOutboundTrackStatsUpdate = { -// [Property in keyof ObservedOutboundTrackStats]: ObservedOutboundTrackStats[Property]; -// } - -export declare interface ObservedOutboundVideoTrack { - on(event: U, listener: (...args: ObservedOutboundVideoTrackEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedOutboundVideoTrackEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedOutboundVideoTrackEvents[U]) => void): this; - emit(event: U, ...args: ObservedOutboundVideoTrackEvents[U]): boolean; - update(sample: OutboundVideoTrack, timestamp: number): void; -} - -export class ObservedOutboundVideoTrack extends EventEmitter { - public readonly created = Date.now(); - public visited = false; - - // timestamp of the MEDIA_TRACK_ADDED event - public added?: number; - // timestamp of the MEDIA_TRACK_REMOVED event - public removed?: number; - - public contentType: 'lowmotion' | 'standard' | 'highmotion' = 'standard'; - public codec?: SupportedVideoCodecType; - public highestLayer?: ObservedOutboundVideoTrackStats; - - public bitrate = 0; - public jitter?: number; - public rttInMs?: number; - public marker?: string; - - public totalLostPackets = 0; - public totalSentPackets = 0; - public totalSentBytes = 0; - public totalSentFrames = 0; - - public deltaLostPackets = 0; - public deltaSentPackets = 0; - public deltaSentBytes = 0; - public deltaSentFrames = 0; - public deltaEncodedFrames = 0; - - public sendingBitrate = 0; - - private readonly _stats = new Map(); - private _lastMaxStatsTimestamp = 0; - - private _closed = false; - private _updated = Date.now(); - public readonly remoteInboundTracks = new Map(); - - public score?: CalculatedScore; - public ωpendingIssuesForScores: ClientIssue[] = []; - - public constructor( - private readonly _model: ObservedOutboundVideoTrackModel, - public readonly peerConnection: ObservedPeerConnection, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.peerConnection.serviceId; - } - - public get roomId() { - return this.peerConnection.roomId; - } - - public get callId() { - return this.peerConnection.callId; - } - - public get clientId() { - return this.peerConnection.clientId; - } - - public get mediaUnitId() { - return this.peerConnection.mediaUnitId; - } - - public get peerConnectionId() { - return this.peerConnection.peerConnectionId; - } - - public get trackId() { - return this._model.trackId; - } - - public get sfuStreamId() { - return this._model.sfuStreamId; - } - - public get reports() { - return this.peerConnection.reports; - } - - public get updated() { - return this._updated; - } - - public get statsTimestamp() { - return this._lastMaxStatsTimestamp; - } - - public get stats(): ReadonlyMap { - return this._stats; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - this.emit('close'); - } - - public update(sample: OutboundVideoTrack, statsTimestamp: number): void { - if (this._closed) return; - - const now = Date.now(); - const report: OutboundAudioTrackReport | OutboundVideoTrackReport = { - serviceId: this.peerConnection.client.serviceId, - roomId: this.peerConnection.client.call.roomId, - callId: this.peerConnection.client.call.callId, - clientId: this.peerConnection.client.clientId, - userId: this.peerConnection.client.userId, - mediaUnitId: this.peerConnection.client.mediaUnitId, - peerConnectionId: this.peerConnection.peerConnectionId, - ...sample, - timestamp: statsTimestamp, - sampleSeq: -1, - marker: this.marker, - }; - - this.reports.addOutboundVideoTrackReport(report); - - const elapsedTimeInMs = Math.max(1, now - this._updated); - const lastStat = this._stats.get(sample.ssrc); - const rttInMs = sample.roundTripTime ? sample.roundTripTime * 1000 : undefined; - let bitrate = 0; - let deltaLostPackets = 0; - let deltaSentPackets = 0; - let deltaSentBytes = 0; - - if (sample.bytesSent && lastStat?.bytesSent && lastStat.bytesSent < sample.bytesSent) { - bitrate = (sample.bytesSent - lastStat.bytesSent) * 8 / (elapsedTimeInMs / 1000); - } - if (sample.packetsLost && lastStat?.packetsLost && lastStat.packetsLost < sample.packetsLost) { - deltaLostPackets = sample.packetsLost - lastStat.packetsLost; - } - if (sample.packetsSent && lastStat?.packetsSent && lastStat.packetsSent < sample.packetsSent) { - deltaSentPackets = sample.packetsSent - lastStat.packetsSent; - } - if (sample.bytesSent && lastStat?.bytesSent && lastStat.bytesSent < sample.bytesSent) { - deltaSentBytes = sample.bytesSent - lastStat.bytesSent; - } - - let deltaEncodedFrames: number | undefined; - let deltaSentFrames: number | undefined; - - const videoSample = sample as OutboundVideoTrack; - const lastVideoStats = lastStat as OutboundVideoTrack | undefined; - - if (videoSample?.framesEncoded && lastVideoStats?.framesEncoded && lastVideoStats.framesEncoded < videoSample.framesEncoded) { - deltaEncodedFrames = videoSample.framesEncoded - lastVideoStats.framesEncoded; - } - if (videoSample?.framesSent && lastVideoStats?.framesSent && lastVideoStats.framesSent < videoSample.framesSent) { - deltaSentFrames = videoSample.framesSent - lastVideoStats.framesSent; - } - - if (videoSample.qualityLimitationReason && lastVideoStats?.qualityLimitationReason !== videoSample.qualityLimitationReason) { - this.emit('qualitylimitationchanged', videoSample.qualityLimitationReason); - } - - const stats: ObservedOutboundVideoTrackStats = { - ...sample, - rttInMs, - bitrate, - ssrc: sample.ssrc, - - deltaLostPackets, - deltaSentPackets, - deltaSentBytes, - deltaEncodedFrames, - deltaSentFrames, - - statsTimestamp, - }; - - this._stats.set(sample.ssrc, stats); - - this.visited = true; - // a peer connection is active if it has at least one active track - this.peerConnection.visited = true; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } - - private _lastUpdateMetrics?: number; - - public updateMetrics() { - let maxStatsTimestamp = 0; - let rttInMsSum = 0; - let jitterSum = 0; - let size = 0; - - this.bitrate = 0; - this.rttInMs = undefined; - - this.sendingBitrate = 0; - this.deltaLostPackets = 0; - this.deltaSentPackets = 0; - this.deltaSentBytes = 0; - this.deltaSentFrames = 0; - this.deltaEncodedFrames = 0; - - const highestLayer = { ssrc: 0, bitrate: -1 }; - - for (const [ ssrc, stats ] of this._stats) { - if (stats.statsTimestamp <= this._lastMaxStatsTimestamp) continue; - - this.deltaLostPackets += stats.deltaLostPackets ?? 0; - this.deltaSentPackets += stats.deltaSentPackets ?? 0; - this.deltaSentBytes += stats.deltaSentBytes ?? 0; - this.deltaSentFrames += stats.deltaSentFrames ?? 0; - this.deltaEncodedFrames += stats.deltaEncodedFrames ?? 0; - this.bitrate += stats.bitrate; - - maxStatsTimestamp = Math.max(maxStatsTimestamp, stats.statsTimestamp); - - rttInMsSum += stats.rttInMs ?? 0; - jitterSum += stats.jitter ?? 0; - ++size; - - if (stats.bitrate > highestLayer.bitrate) { - highestLayer.ssrc = ssrc; - highestLayer.bitrate = stats.bitrate; - } - } - - const now = Date.now(); - - if (this._lastUpdateMetrics) { - this.sendingBitrate = (this.deltaSentBytes * 8) / ((now - this._lastUpdateMetrics) / 1000); - } - this._lastUpdateMetrics = now; - this.highestLayer = this._stats.get(highestLayer.ssrc); - this.totalLostPackets += this.deltaLostPackets; - this.totalSentPackets += this.deltaSentPackets; - this.totalSentBytes += this.deltaSentBytes; - this.totalSentFrames += this.deltaSentFrames; - - this.rttInMs = rttInMsSum / Math.max(size, 1); - this.jitter = jitterSum / Math.max(size, 1); - - this._lastMaxStatsTimestamp = maxStatsTimestamp; - this._updateQualityScore(maxStatsTimestamp); - } - - private _updateQualityScore(timestamp: number) { - const newIssues = this.ωpendingIssuesForScores; - const score = calculateBaseVideoScore(this, newIssues); - - if (0 < newIssues.length) { - this.ωpendingIssuesForScores = []; - } - - if (!score) return (this.score = undefined); - - score.timestamp = timestamp; - this.score = score; - - this.emit('score', this.score); - } -} diff --git a/src/ObservedPeerConnection.ts b/src/ObservedPeerConnection.ts index 334079c..25cb2bc 100644 --- a/src/ObservedPeerConnection.ts +++ b/src/ObservedPeerConnection.ts @@ -1,65 +1,146 @@ import { EventEmitter } from 'events'; -import { PeerConnectionTransport } from '@observertc/sample-schemas-js'; import { ObservedClient } from './ObservedClient'; -import { CallEventReport, PeerConnectionTransportReport } from '@observertc/report-schemas-js'; -import { ObservedICE } from './ObservedICE'; +import { CertificateStats, CodecStats, DataChannelStats, IceCandidateStats, InboundRtpStats, InboundTrackSample, MediaPlayoutStats, MediaSourceStats, OutboundRtpStats, OutboundTrackSample, PeerConnectionSample, PeerConnectionTransportStats, RemoteInboundRtpStats, RemoteOutboundRtpStats } from './schema/ClientSample'; +import { ObservedInboundRtp } from './ObservedInboundRtp'; +import { createLogger } from './common/logger'; +import { MediaKind } from './common/types'; +import { ObservedOutboundRtp } from './ObservedOutboundRtp'; +import { ObservedCertificate } from './ObservedCertificate'; +import { ObservedCodec } from './ObservedCodec'; import { ObservedDataChannel } from './ObservedDataChannel'; -import { PartialBy } from './common/utils'; -import { ObservedInboundAudioTrack, ObservedInboundAudioTrackModel } from './ObservedInboundAudioTrack'; -import { ObservedInboundVideoTrack, ObservedInboundVideoTrackModel } from './ObservedInboundVideoTrack'; -import { ObservedOutboundAudioTrack, ObservedOutboundAudioTrackModel } from './ObservedOutboundAudioTrack'; -import { ObservedOutboundVideoTrack, ObservedOutboundVideoTrackModel } from './ObservedOutboundVideoTrack'; -import { CalculatedScore, getRttScore } from './common/CalculatedScore'; -import { ClientIssue } from './monitors/CallSummary'; +import { ObservedIceCandidate } from './ObservedIceCandidate'; +import { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +import { ObservedIceTransport } from './ObservedIceTransport'; +import { ObservedMediaSource } from './ObservedMediaSource'; +import { ObservedPeerConnectionTransport } from './ObservedPeerConnectionTransport'; +import { ObservedMediaPlayout } from './ObservedMediaPlayout'; +import { ObservedRemoteInboundRtp } from './ObservedRemoteInboundRtp'; +import { ObservedRemoteOutboundRtp } from './ObservedRemoteOutboundRtp'; +import { ObservedInboundTrack } from './ObservedInboundTrack'; +import { ObservedOutboundTrack } from './ObservedOutboundTrack'; +import { CalculatedScore } from './scores/CalculatedScore'; +import { ObservedTurnServer } from './ObservedTurnServer'; +import { getMedian } from './common/utils'; + +const logger = createLogger('ObservedPeerConnection'); export type ObservedPeerConnectionEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], - score: [CalculatedScore], - newinboudaudiotrack: [ObservedInboundAudioTrack], - newinboudvideotrack: [ObservedInboundVideoTrack], - newoutboundaudiotrack: [ObservedOutboundAudioTrack], - newoutboundvideotrack: [ObservedOutboundVideoTrack], - newdatachannel: [ObservedDataChannel], -}; - -export type ObservedPeerConnectionModel = { - peerConnectionId: string; - label?: string; + iceconnectionstatechange: [ + { + state: string; + } + ]; + icegatheringstatechange: [ + { + state: string; + } + ]; + connectionstatechange: [ + { + state: string; + } + ]; + selectedcandidatepair: []; + + 'added-certificate': [ObservedCertificate]; + 'added-codec': [ObservedCodec]; + 'added-data-channel': [ObservedDataChannel]; + 'added-ice-candidate': [ObservedIceCandidate]; + 'added-ice-candidate-pair': [ObservedIceCandidatePair]; + 'added-ice-transport': [ObservedIceTransport]; + 'added-inbound-rtp': [ObservedInboundRtp]; + 'added-inbound-track': [ObservedInboundTrack]; + 'added-media-playout': [ObservedMediaPlayout]; + 'added-media-source': [ObservedMediaSource]; + 'added-outbound-rtp': [ObservedOutboundRtp]; + 'added-outbound-track': [ObservedOutboundTrack]; + 'added-peer-connection-transport': [ObservedPeerConnectionTransport]; + 'added-remote-inbound-rtp': [ObservedRemoteInboundRtp]; + 'added-remote-outbound-rtp': [ObservedRemoteOutboundRtp]; + 'removed-certificate': [ObservedCertificate]; + 'removed-codec': [ObservedCodec]; + 'removed-data-channel': [ObservedDataChannel]; + 'removed-ice-candidate': [ObservedIceCandidate]; + 'removed-ice-candidate-pair': [ObservedIceCandidatePair]; + 'removed-ice-transport': [ObservedIceTransport]; + 'removed-inbound-rtp': [ObservedInboundRtp]; + 'removed-inbound-track': [ObservedInboundTrack]; + 'removed-media-playout': [ObservedMediaPlayout]; + 'removed-media-source': [ObservedMediaSource]; + 'removed-outbound-rtp': [ObservedOutboundRtp]; + 'removed-outbound-track': [ObservedOutboundTrack]; + 'removed-peer-connection-transport': [ObservedPeerConnectionTransport]; + 'removed-remote-inbound-rtp': [ObservedRemoteInboundRtp]; + 'removed-remote-outbound-rtp': [ObservedRemoteOutboundRtp]; + 'updated-inbound-rtp': [ObservedInboundRtp]; + 'updated-outbound-rtp': [ObservedOutboundRtp]; + 'updated-inbound-track': [ObservedInboundTrack]; + 'updated-outbound-track': [ObservedOutboundTrack]; + 'updated-ice-candidate-pair': [ObservedIceCandidatePair]; + 'updated-ice-transport': [ObservedIceTransport]; + 'updated-peer-connection-transport': [ObservedPeerConnectionTransport]; + 'updated-media-source': [ObservedMediaSource]; + 'updated-media-playout': [ObservedMediaPlayout]; + 'updated-data-channel': [ObservedDataChannel]; + 'updated-ice-candidate': [ObservedIceCandidate]; + 'updated-certificate': [ObservedCertificate]; + 'updated-codec': [ObservedCodec]; + 'updated-remote-inbound-rtp': [ObservedRemoteInboundRtp]; + 'updated-remote-outbound-rtp': [ObservedRemoteOutboundRtp]; + 'muted-inbound-track': [ObservedInboundTrack]; + 'muted-outbound-track': [ObservedOutboundTrack]; + 'unmuted-inbound-track': [ObservedInboundTrack]; + 'unmuted-outbound-track': [ObservedOutboundTrack]; + + 'update': [], + close: []; }; -export type ObservedPeerConnectionStats = Omit; - export declare interface ObservedPeerConnection { - on(event: U, listener: (...args: ObservedPeerConnectionEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedPeerConnectionEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedPeerConnectionEvents[U]) => void): this; + on( + event: U, + listener: (...args: ObservedPeerConnectionEvents[U]) => void + ): this; + off( + event: U, + listener: (...args: ObservedPeerConnectionEvents[U]) => void + ): this; + once( + event: U, + listener: (...args: ObservedPeerConnectionEvents[U]) => void + ): this; emit(event: U, ...args: ObservedPeerConnectionEvents[U]): boolean; } export class ObservedPeerConnection extends EventEmitter { - public readonly created = Date.now(); - public visited = true; + private _visited = true; + public appData?: Record; + public readonly calculatedScore: CalculatedScore = { + weight: 1, + value: undefined, + }; + + public closed = false; // timestamp of the PEER_CONNECTION_OPENED event - public opened?: number; + public openedAt?: number; // timestamp of the PEER_CONNECTION_CLOSED event - public closedTimestamp?: number; - - private _elapsedTimeSinceLastUpdate?: number; - private _statsTimestamp?: number; - private _stabilityScores: number[] = []; + public closedAt?: number; + public updated = Date.now(); - public ωpendingIssuesForScores: ClientIssue[] = []; + public connectionState?: string; + public iceConnectionState?: string; + public iceGatheringState?: string; - public score?: CalculatedScore; + public availableIncomingBitrate = 0; + public availableOutgoingBitrate = 0; public totalInboundPacketsLost = 0; public totalInboundPacketsReceived = 0; public totalOutboundPacketsSent = 0; public totalDataChannelBytesSent = 0; public totalDataChannelBytesReceived = 0; + public totalDataChannelMessagesSent = 0; + public totalDataChannelMessagesReceived = 0; public totalSentAudioBytes = 0; public totalSentVideoBytes = 0; @@ -75,182 +156,165 @@ export class ObservedPeerConnection extends EventEmitter { public deltaOutboundPacketsSent = 0; public deltaDataChannelBytesSent = 0; public deltaDataChannelBytesReceived = 0; + public deltaDataChannelMessagesSent = 0; + public deltaDataChannelMessagesReceived = 0; public deltaInboundReceivedBytes = 0; public deltaOutboundSentBytes = 0; - + public deltaReceivedAudioBytes = 0; public deltaReceivedVideoBytes = 0; public deltaReceivedAudioPackets = 0; public deltaReceivedVideoPackets = 0; public deltaSentAudioBytes = 0; public deltaSentVideoBytes = 0; + public deltaTransportSentBytes = 0; + public deltaTransportReceivedBytes = 0; + public receivingPacketsPerSecond = 0; public sendingPacketsPerSecond = 0; public sendingAudioBitrate = 0; public sendingVideoBitrate = 0; public receivingAudioBitrate = 0; public receivingVideoBitrate = 0; - - public avgRttInMs?: number; - public avgJitter?: number; - - private _closed = false; - private _updated = Date.now(); - private _sample?: ObservedPeerConnectionStats; - private _marker?: string; - - public readonly ICE = ObservedICE.create(this); - private readonly _inboundAudioTracks = new Map(); - private readonly _inboundVideoTracks = new Map(); - private readonly _outboundAudioTracks = new Map(); - private readonly _outboundVideoTracks = new Map(); - private readonly _dataChannels = new Map(); - - public constructor( - private readonly _model: ObservedPeerConnectionModel, - public readonly client: ObservedClient, - ) { + + public currentRttInMs?: number; + public currentJitter?: number; + + public usingTCP = false; + public usingTURN = false; + + public observedTurnServer?: ObservedTurnServer; + public readonly observedCertificates = new Map(); + public readonly observedCodecs = new Map(); + public readonly observedDataChannels = new Map(); + public readonly observedIceCandidates = new Map(); + public readonly observedIceCandidatesPair = new Map(); + public readonly observedIceTransports = new Map(); + public readonly observedInboundRtps = new Map(); + public readonly observedInboundTracks = new Map(); + public readonly observedMediaPlayouts = new Map(); + public readonly observedMediaSources = new Map(); + public readonly observedOutboundRtps = new Map(); + public readonly observedOutboundTracks = new Map(); + public readonly observedPeerConnectionTransports = new Map(); + public readonly observedRemoteInboundRtps = new Map(); + public readonly observedRemoteOutboundRtps = new Map(); + + public constructor(public readonly peerConnectionId: string, public readonly client: ObservedClient) { super(); this.setMaxListeners(Infinity); } - public get serviceId() { - return this.client.serviceId; - } - - public get roomId() { - return this.client.roomId; - } - - public get callId() { - return this.client.callId; - } - - public get clientId() { - return this.client.clientId; - } - - public get mediaUnitId() { - return this.client.mediaUnitId; - } - - public get label() { - return this._model.label; + public get score() { + return this.calculatedScore.value; } - public set label(value: string | undefined) { - this._model.label = value; - } + public get visited() { + const visited = this._visited; - public get usingTURN() { - return this.ICE.usingTURN; - } + this._visited = false; - public get availableOutgoingBitrate() { - return this.ICE.stats?.availableOutgoingBitrate; + return visited; } - public get availableIncomingBitrate() { - return this.ICE.stats?.availableIncomingBitrate; + public get codecs() { + return [ ...this.observedCodecs.values() ]; } - public get peerConnectionId(): string { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.peerConnectionId!; + public get inboundRtps() { + return [ ...this.observedInboundRtps.values() ]; } - public get reports() { - return this.client.reports; + public get remoteOutboundRtps() { + return [ ...this.observedRemoteOutboundRtps.values() ]; } - public get stats(): ObservedPeerConnectionStats | undefined { - return this._sample; + public get outboundRtps() { + return [ ...this.observedOutboundRtps.values() ]; } - public get updated(): number { - return this._updated; + public get remoteInboundRtps() { + return [ ...this.observedRemoteInboundRtps.values() ]; } - public get inboundAudioTracks(): ReadonlyMap { - return this._inboundAudioTracks; + public get mediaSources() { + return [ ...this.observedMediaSources.values() ]; } - public get inboundVideoTracks(): ReadonlyMap { - return this._inboundVideoTracks; + public get mediaPlayouts() { + return [ ...this.observedMediaPlayouts.values() ]; } - public get outboundAudioTracks(): ReadonlyMap { - return this._outboundAudioTracks; + public get dataChannels() { + return [ ...this.observedDataChannels.values() ]; } - public get outboundVideoTracks(): ReadonlyMap { - return this._outboundVideoTracks; + public get peerConnectionTransports() { + return [ ...this.observedPeerConnectionTransports.values() ]; } - public get dataChannels(): ReadonlyMap { - return this._dataChannels; + public get iceTransports() { + return [ ...this.observedIceTransports.values() ]; } - public get uptimeInMs() { - return this._updated - this.created; + public get iceCandidates() { + return [ ...this.observedIceCandidates.values() ]; } - public get marker() { - return this._marker; + public get iceCandidatePairs() { + return [ ...this.observedIceCandidatesPair.values() ]; } - public set marker(value: string | undefined) { - this._marker = value; - this._inboundAudioTracks.forEach((track) => (track.marker = value)); - this._inboundVideoTracks.forEach((track) => (track.marker = value)); - this._outboundAudioTracks.forEach((track) => (track.marker = value)); - this._outboundVideoTracks.forEach((track) => (track.marker = value)); - this._dataChannels.forEach((channel) => (channel.marker = value)); - this.ICE.marker = value; + public get certificates() { + return [ ...this.observedCertificates.values() ]; } - public addEventReport(params: PartialBy, 'timestamp'>) { - this.reports.addCallEventReport({ - ...params, - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - roomId: this.roomId, - callId: this.callId, - clientId: this.clientId, - userId: this.client.userId, - peerConnectionId: this.peerConnectionId, - marker: this.client.marker, - timestamp: params.timestamp ?? Date.now(), - }); + public get selectedIceCandidatePairs() { + return this.iceTransports.map((iceTransport) => iceTransport.getSelectedCandidatePair()) + .filter((pair) => pair !== undefined) as ObservedIceCandidatePair[]; } - public get closed() { - return this._closed; + public get selectedIceCandiadtePairForTurn() { + return this.selectedIceCandidatePairs + .filter((pair) => + pair.getLocalCandidate()?.candidateType === 'relay' && + pair.getRemoteCandidate()?.url?.startsWith('turn:') + ); } public close() { - if (this._closed) return; - this._closed = true; - - Array.from(this._inboundAudioTracks.values()).forEach((track) => track.close()); - Array.from(this._inboundVideoTracks.values()).forEach((track) => track.close()); - Array.from(this._outboundAudioTracks.values()).forEach((track) => { - this.client.call.sfuStreamIdToOutboundAudioTrack.delete(track.sfuStreamId ?? ''); - track.close(); - }); - Array.from(this._outboundVideoTracks.values()).forEach((track) => { - this.client.call.sfuStreamIdToOutboundVideoTrack.delete(track.sfuStreamId ?? ''); - track.close(); - }); - + if (this.closed) return; + this.closed = true; + + this.observedCertificates.clear(); + this.observedCodecs.clear(); + this.observedDataChannels.clear(); + this.observedIceCandidates.clear(); + this.observedIceCandidatesPair.clear(); + this.observedIceTransports.clear(); + this.observedInboundRtps.clear(); + this.observedInboundTracks.clear(); + this.observedMediaPlayouts.clear(); + this.observedMediaSources.clear(); + this.observedOutboundRtps.clear(); + this.observedOutboundTracks.clear(); + this.observedPeerConnectionTransports.clear(); + this.observedRemoteInboundRtps.clear(); + this.observedRemoteOutboundRtps.clear(); + + this.client.call.observer.observedTURN.removePeerConnection(this); + + if (!this.closedAt) this.closedAt = Date.now(); + this.emit('close'); } - public getTrack(trackId: string): ObservedInboundAudioTrack | ObservedInboundVideoTrack | ObservedOutboundAudioTrack | ObservedOutboundVideoTrack | undefined { - return this._inboundAudioTracks.get(trackId) ?? this._inboundVideoTracks.get(trackId) ?? this._outboundAudioTracks.get(trackId) ?? this._outboundVideoTracks.get(trackId); - } + public accept(sample: PeerConnectionSample) { + if (this.closed) return; + this._visited = true; - public resetMetrics() { + this.availableIncomingBitrate = 0; + this.availableOutgoingBitrate = 0; this.deltaInboundPacketsLost = 0; this.deltaInboundPacketsReceived = 0; this.deltaOutboundPacketsSent = 0; @@ -258,316 +322,767 @@ export class ObservedPeerConnection extends EventEmitter { this.deltaDataChannelBytesReceived = 0; this.deltaInboundReceivedBytes = 0; this.deltaOutboundSentBytes = 0; - this.deltaReceivedAudioBytes = 0; this.deltaReceivedVideoBytes = 0; this.deltaReceivedAudioPackets = 0; this.deltaReceivedVideoPackets = 0; this.deltaSentAudioBytes = 0; this.deltaSentVideoBytes = 0; + this.deltaTransportReceivedBytes = 0; + this.deltaTransportSentBytes = 0; + this.sendingAudioBitrate = 0; this.sendingVideoBitrate = 0; this.receivingAudioBitrate = 0; this.receivingVideoBitrate = 0; - this.ICE.resetMetrics(); + const now = Date.now(); + const elapsedTimeInMs = now - this.updated; + const elapsedTimeInSec = elapsedTimeInMs / 1000; + const rttMeasurementsInSec: number[] = []; + const jitterMeasurements: number[] = []; + + if (sample.certificates) { + for (const certificate of sample.certificates) { + this._updateCertificateStats(certificate); + } + } + if (sample.codecs) { + for (const codec of sample.codecs) { + this._updateCodecStats(codec); + } + } + if (sample.dataChannels) { + for (const dataChannel of sample.dataChannels) { + const observedDataChannel = this._updateDataChannelStats(dataChannel); + + if (!observedDataChannel) continue; + + this.deltaDataChannelBytesSent += observedDataChannel.deltaBytesSent; + this.deltaDataChannelBytesReceived += observedDataChannel.deltaBytesReceived; + this.deltaDataChannelMessagesSent += observedDataChannel.deltaMessagesSent; + this.deltaDataChannelMessagesReceived += observedDataChannel.deltaMessagesReceived; + } + } + if (sample.iceCandidates) { + for (const iceCandidate of sample.iceCandidates) { + this._updateIceCandidateStats(iceCandidate); + } + } + if (sample.iceCandidatePairs) { + for (const iceCandidatePair of sample.iceCandidatePairs) { + const observedCandidatePair = this._updateIceCandidatePairStats(iceCandidatePair); + + if (!observedCandidatePair) continue; + + if (observedCandidatePair.currentRoundTripTime) { + rttMeasurementsInSec.push(observedCandidatePair.currentRoundTripTime); + } + if (observedCandidatePair.availableIncomingBitrate) { + this.availableIncomingBitrate += observedCandidatePair.availableIncomingBitrate; + } + if (observedCandidatePair.availableOutgoingBitrate) { + this.availableOutgoingBitrate += observedCandidatePair.availableOutgoingBitrate; + } + } + } + if (sample.iceTransports) { + for (const iceTransport of sample.iceTransports) { + const observedIceTransport = this._updateIceTransportStats(iceTransport); + + if (!observedIceTransport) return; + + observedIceTransport.bytesReceived; + + } + } + if (sample.inboundRtps) { + for (const inboundRtp of sample.inboundRtps) { + const observedInboundRtp = this._updateInboundRtpStats(inboundRtp); + + if (!observedInboundRtp) continue; + + this.deltaInboundPacketsLost += observedInboundRtp.deltaLostPackets; + this.deltaInboundPacketsReceived += observedInboundRtp.deltaReceivedPackets; + this.deltaInboundReceivedBytes += observedInboundRtp.deltaBytesReceived; + + switch (inboundRtp.kind) { + case 'audio': + this.deltaReceivedAudioBytes += observedInboundRtp.deltaBytesReceived; + this.deltaReceivedAudioPackets += observedInboundRtp.deltaReceivedPackets; + break; + case 'video': + this.deltaReceivedVideoBytes += observedInboundRtp.deltaBytesReceived; + this.deltaReceivedVideoPackets += observedInboundRtp.deltaReceivedPackets; + break; + } + + if (observedInboundRtp.jitter) { + jitterMeasurements.push(observedInboundRtp.jitter); + } + } + } + if (sample.mediaPlayouts) { + for (const mediaPlayout of sample.mediaPlayouts) { + this._updateMediaPlayoutStats(mediaPlayout); + } + } + if (sample.mediaSources) { + for (const mediaSource of sample.mediaSources) { + this._updateMediaSourceStats(mediaSource); + } + } + if (sample.outboundRtps) { + for (const outboundRtp of sample.outboundRtps) { + const observedOutboundRtp = this._updateOutboundRtpStats(outboundRtp); + + if (!observedOutboundRtp) continue; + + this.deltaOutboundPacketsSent += observedOutboundRtp.deltaPacketsSent ?? 0; + this.deltaOutboundSentBytes += observedOutboundRtp.deltaBytesSent ?? 0; + + switch (outboundRtp.kind) { + case 'audio': + this.deltaSentAudioBytes += observedOutboundRtp.deltaBytesSent; + this.deltaSentAudioBytes += observedOutboundRtp.deltaPacketsSent; + break; + case 'video': + this.deltaSentVideoBytes += observedOutboundRtp.deltaBytesSent; + this.deltaSentVideoBytes += observedOutboundRtp.deltaPacketsSent; + break; + } + + } + } + if (sample.peerConnectionTransports) { + for (const peerConnectionTransport of sample.peerConnectionTransports) { + const observedTransport = this._updatePeerConnectionTransportStats(peerConnectionTransport); + + if (!observedTransport) continue; + + } + } + if (sample.remoteInboundRtps) { + for (const remoteInboundRtp of sample.remoteInboundRtps) { + const observedRemoteInboundRtp = this._updateRemoteInboundRtpStats(remoteInboundRtp); + + if (!observedRemoteInboundRtp) continue; + + if (observedRemoteInboundRtp.roundTripTime) { + rttMeasurementsInSec.push(observedRemoteInboundRtp.roundTripTime); + } + } + } + if (sample.remoteOutboundRtps) { + for (const remoteOutboundRtp of sample.remoteOutboundRtps) { + const observedRemoteOutboundRtp = this._updateRemoteOutboundRtpStats(remoteOutboundRtp); + + if (!observedRemoteOutboundRtp) continue; + } + } + + // tracks should be updated last as they are derived stats + // and depends on base stats but they all received in the sample sample + if (sample.inboundTracks) { + for (const inboundTrack of sample.inboundTracks) { + this._updateInboundTrackSample(inboundTrack); + } + } + if (sample.outboundTracks) { + for (const outboundTrack of sample.outboundTracks) { + this._updateOutboundTrackSample(outboundTrack); + } + } + + this.totalInboundPacketsLost += this.deltaInboundPacketsLost; + this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived; + this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent; + this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent; + this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived; + this.totalDataChannelMessagesSent += this.deltaDataChannelMessagesSent; + this.totalDataChannelMessagesReceived += this.deltaDataChannelMessagesReceived; + this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes; + this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes; + this.totalSentAudioBytes += this.deltaSentAudioBytes; + this.totalSentVideoBytes += this.deltaSentVideoBytes; + this.totalReceivedAudioPacktes += this.deltaReceivedAudioPackets; + this.totalReceivedVideoPackets += this.deltaReceivedVideoPackets; + this.totalSentAudioPackets += this.deltaSentAudioBytes; + this.totalSentVideoPackets += this.deltaSentVideoBytes; + + this.receivingPacketsPerSecond = this.deltaInboundPacketsReceived / elapsedTimeInSec; + this.sendingPacketsPerSecond = this.deltaOutboundPacketsSent / elapsedTimeInSec; + this.sendingAudioBitrate = (this.deltaSentAudioBytes * 8) / elapsedTimeInSec; + this.sendingVideoBitrate = (this.deltaSentVideoBytes * 8) / elapsedTimeInSec; + this.receivingAudioBitrate = (this.deltaReceivedAudioBytes * 8) / elapsedTimeInSec; + this.receivingVideoBitrate = (this.deltaReceivedVideoBytes * 8) / elapsedTimeInSec; + + if (rttMeasurementsInSec.length > 0) { + this.currentRttInMs = getMedian(rttMeasurementsInSec, false) * 1000; + } else { + this.currentRttInMs = undefined; + } + if (jitterMeasurements.length > 0) { + this.currentJitter = getMedian(jitterMeasurements, false); + } else { + this.currentJitter = undefined; + } + const wasUsingTURN = this.usingTURN; + const selectedIceCandidatePairs = this.selectedIceCandidatePairs; + const selectedCandidatePairForTurn: ObservedIceCandidatePair[] = []; + + this.usingTCP = false; + this.usingTURN = false; + + for (const selectedCandidatePair of selectedIceCandidatePairs) { + if (selectedCandidatePair.getLocalCandidate()?.protocol === 'tcp') { + this.usingTCP = true; + } + if (selectedCandidatePair.getLocalCandidate()?.candidateType === 'relay' && selectedCandidatePair.getRemoteCandidate()?.url?.startsWith('turn:')) { + selectedCandidatePairForTurn.push(selectedCandidatePair); + this.usingTURN = true; + } + this.deltaTransportReceivedBytes += selectedCandidatePair.deltaBytesReceived; + this.deltaTransportSentBytes += selectedCandidatePair.deltaBytesSent; + } + + if (this.usingTURN) { + if (!this.observedTurnServer) { + this.observedTurnServer = this.client.call.observer.observedTURN.addPeerConnection(this); + } + this.observedTurnServer?.updateTurnUsage(...selectedCandidatePairForTurn); + } else if (wasUsingTURN) { + if (!this.usingTURN) { + this.client.call.observer.observedTURN.removePeerConnection(this); + } + } + this.calculatedScore.value = sample.score; + this.updated = now; + this._checkVisited(); + + this.emit('update'); } - public update(sample: PeerConnectionTransport, timestamp: number) { - if (this._closed) return; - if (sample.peerConnectionId !== this._model.peerConnectionId) throw new Error(`TransportId mismatch. PeerConnectionId: ${ this._model.peerConnectionId } TransportId: ${ sample.transportId}`); + private _checkVisited() { + for (const certificate of [ ...this.observedCertificates.values() ]) { + if (certificate.visited) continue; - this._sample = sample; - if (this._model.label !== sample.label) { - this._model.label = sample.label; + this.observedCertificates.delete(certificate.id); } - const now = Date.now(); - const report: PeerConnectionTransportReport = { - serviceId: this.client.call.serviceId, - roomId: this.client.call.roomId, - callId: this.client.call.callId, - clientId: this.client.clientId, - userId: this.client.userId, - mediaUnitId: this.client.mediaUnitId, - ...sample, - timestamp, - sampleSeq: -1, // deprecated - marker: this.marker, - }; - - this.reports.addPeerConnectionTransportReports(report); - this._elapsedTimeSinceLastUpdate = now - this._updated; - this.visited = true; - this._updated = now; - this._statsTimestamp = timestamp; - this.emit('update', { - elapsedTimeInMs: this._elapsedTimeSinceLastUpdate, - }); - } - - public createInboundAudioTrack(config: ObservedInboundAudioTrackModel): ObservedInboundAudioTrack { - if (this._closed) throw new Error(`PeerConnection ${this.peerConnectionId} is closed`); - - const result = new ObservedInboundAudioTrack(config, this); + for (const codec of [ ...this.observedCodecs.values() ]) { + if (codec.visited) continue; + + this.observedCodecs.delete(codec.id); + this.emit('removed-codec', codec); + } + for (const dataChannel of [ ...this.observedDataChannels.values() ]) { + if (dataChannel.visited) continue; + + this.observedDataChannels.delete(dataChannel.id); + this.emit('removed-data-channel', dataChannel); + } + for (const iceCandidate of [ ...this.observedIceCandidates.values() ]) { + if (iceCandidate.visited) continue; + + this.observedIceCandidates.delete(iceCandidate.id); + this.emit('removed-ice-candidate', iceCandidate); + } + for (const iceCandidatePair of [ ...this.observedIceCandidatesPair.values() ]) { + if (iceCandidatePair.visited) continue; + + this.observedIceCandidatesPair.delete(iceCandidatePair.id); + this.emit('removed-ice-candidate-pair', iceCandidatePair); + } + for (const iceTransport of [ ...this.observedIceTransports.values() ]) { + if (iceTransport.visited) continue; + + this.observedIceTransports.delete(iceTransport.id); + this.emit('removed-ice-transport', iceTransport); + } + for (const inboundRtp of [ ...this.observedInboundRtps.values() ]) { + if (inboundRtp.visited) continue; + + this.observedInboundRtps.delete(inboundRtp.ssrc); + this.emit('removed-inbound-rtp', inboundRtp); + } + for (const inboundTrack of [ ...this.observedInboundTracks.values() ]) { + if (inboundTrack.visited) continue; + + this.observedInboundTracks.delete(inboundTrack.id); + this.emit('removed-inbound-track', inboundTrack); + } + for (const mediaPlayout of [ ...this.observedMediaPlayouts.values() ]) { + if (mediaPlayout.visited) continue; + + this.observedMediaPlayouts.delete(mediaPlayout.id); + this.emit('removed-media-playout', mediaPlayout); + } + for (const mediaSource of [ ...this.observedMediaSources.values() ]) { + if (mediaSource.visited) continue; + + this.observedMediaSources.delete(mediaSource.id); + this.emit('removed-media-source', mediaSource); + } + for (const outboundRtp of [ ...this.observedOutboundRtps.values() ]) { + if (outboundRtp.visited) continue; + + this.observedOutboundRtps.delete(outboundRtp.ssrc); + this.emit('removed-outbound-rtp', outboundRtp); + } + for (const outboundTrack of [ ...this.observedOutboundTracks.values() ]) { + if (outboundTrack.visited) continue; - result.on('close', () => { - this._inboundAudioTracks.delete(result.trackId); - }); - this._inboundAudioTracks.set(result.trackId, result); + this.observedOutboundTracks.delete(outboundTrack.id); + this.emit('removed-outbound-track', outboundTrack); + } + for (const peerConnectionTransport of [ ...this.observedPeerConnectionTransports.values() ]) { + if (peerConnectionTransport.visited) continue; + + this.observedPeerConnectionTransports.delete(peerConnectionTransport.id); + this.emit('removed-peer-connection-transport', peerConnectionTransport); + } + for (const remoteInboundRtp of [ ...this.observedRemoteInboundRtps.values() ]) { + if (remoteInboundRtp.visited) continue; - this.emit('newinboudaudiotrack', result); + this.observedRemoteInboundRtps.delete(remoteInboundRtp.ssrc); + this.emit('removed-remote-inbound-rtp', remoteInboundRtp); + } + for (const remoteOutboundRtp of [ ...this.observedRemoteOutboundRtps.values() ]) { + if (remoteOutboundRtp.visited) continue; - return result; + this.observedRemoteOutboundRtps.delete(remoteOutboundRtp.ssrc); + this.emit('removed-remote-outbound-rtp', remoteOutboundRtp); + } } - public createInboundVideoTrack(config: ObservedInboundVideoTrackModel): ObservedInboundVideoTrack { - if (this._closed) throw new Error(`PeerConnection ${this.peerConnectionId} is closed`); - - const result = new ObservedInboundVideoTrack(config, this); + private _updateCertificateStats(stats: CertificateStats) { + let observedCertificate = this.observedCertificates.get(stats.id); + + if (!observedCertificate) { + if (!stats.timestamp || !stats.id || !stats.fingerprint) { + return logger.warn( + `ObservedPeerConnection received an invalid CertificateStats (missing timestamp OR id OR fingerprint field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - result.on('close', () => { - this._inboundVideoTracks.delete(result.trackId); - }); - this._inboundVideoTracks.set(result.trackId, result); + observedCertificate = new ObservedCertificate(stats.timestamp, stats.id, this); - this.emit('newinboudvideotrack', result); + observedCertificate.update(stats); - return result; + this.observedCertificates.set(stats.id, observedCertificate); + this.emit('added-certificate', observedCertificate); + } else { + observedCertificate.update(stats); + this.emit('updated-certificate', observedCertificate); + } + + return observedCertificate; } - public createOutboundAudioTrack(config: ObservedOutboundAudioTrackModel): ObservedOutboundAudioTrack { - if (this._closed) throw new Error(`PeerConnection ${this.peerConnectionId} is closed`); - - const result = new ObservedOutboundAudioTrack(config, this); + private _updateCodecStats(stats: CodecStats) { + let observedCodec = this.observedCodecs.get(stats.id); - result.on('close', () => { - this._outboundAudioTracks.delete(result.trackId); - }); - this._outboundAudioTracks.set(result.trackId, result); + if (!observedCodec) { + if (!stats.timestamp || !stats.id || !stats.mimeType) { + return logger.warn( + `ObservedPeerConnection received an invalid CodecStats (missing timestamp OR id OR mimeType field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedCodec = new ObservedCodec(stats.timestamp, stats.id, stats.mimeType, this); - this.emit('newoutboundaudiotrack', result); + observedCodec.update(stats); + + this.observedCodecs.set(stats.id, observedCodec); + this.emit('added-codec', observedCodec); + } else { + observedCodec.update(stats); + } + this.emit('updated-codec', observedCodec); - return result; + return observedCodec; } - public createOutboundVideoTrack(config: ObservedOutboundVideoTrackModel): ObservedOutboundVideoTrack { - if (this._closed) throw new Error(`PeerConnection ${this.peerConnectionId} is closed`); - - const result = new ObservedOutboundVideoTrack(config, this); + private _updateDataChannelStats(stats: DataChannelStats) { + let observedDataChannel = this.observedDataChannels.get(stats.id); + + if (!observedDataChannel) { + if (!stats.timestamp || !stats.id) { + return logger.warn( + `ObservedPeerConnection received an invalid DataChannelStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - result.on('close', () => { - this._outboundVideoTracks.delete(result.trackId); - }); - this._outboundVideoTracks.set(result.trackId, result); + observedDataChannel = new ObservedDataChannel(stats.timestamp, stats.id, this); - this.emit('newoutboundvideotrack', result); + observedDataChannel.update(stats); - return result; + this.observedDataChannels.set(stats.id, observedDataChannel); + this.emit('added-data-channel', observedDataChannel); + } else { + observedDataChannel.update(stats); + } + this.emit('updated-data-channel', observedDataChannel); + + return observedDataChannel; } - public createDataChannel(channelId: number) { - if (this._closed) throw new Error(`PeerConnection ${this.peerConnectionId} is closed`); - - const result = new ObservedDataChannel({ - channelId, - }, this); + private _updateIceCandidateStats(stats: IceCandidateStats) { + let observedIceCandidate = this.observedIceCandidates.get(stats.id); - result.on('close', () => { - this._dataChannels.delete(result.channelId); - }); - this._dataChannels.set(result.channelId, result); + if (!observedIceCandidate) { + if (!stats.timestamp || !stats.id) { + return logger.warn( + `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedIceCandidate = new ObservedIceCandidate(stats.timestamp, stats.id, this); - this.emit('newdatachannel', result); + observedIceCandidate.update(stats); + + this.observedIceCandidates.set(stats.id, observedIceCandidate); + this.emit('added-ice-candidate', observedIceCandidate); + } else { + observedIceCandidate.update(stats); + } + this.emit('updated-ice-candidate', observedIceCandidate); - return result; + return observedIceCandidate; } - public updateMetrics() { - let sumRttInMs = 0; - let sumJitter = 0; - const trackScores: CalculatedScore[] = []; + private _updateIceCandidatePairStats(stats: IceCandidateStats) { + let observedIceCandidatePair = this.observedIceCandidatesPair.get(stats.id); + + if (!observedIceCandidatePair) { + if (!stats.timestamp || !stats.id) { + return logger.warn( + `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - this._inboundAudioTracks.forEach((track) => { - track.updateMetrics(); + observedIceCandidatePair = new ObservedIceCandidatePair(stats.timestamp, stats.id, this); - this.deltaInboundPacketsLost += track.deltaLostPackets; - this.deltaInboundPacketsReceived += track.deltaReceivedPackets; - this.deltaInboundReceivedBytes += track.deltaBytesReceived; - - this.deltaReceivedAudioBytes += track.deltaBytesReceived; - this.deltaReceivedAudioPackets += track.deltaReceivedPackets; + observedIceCandidatePair.update(stats); - this.receivingAudioBitrate += track.bitrate; + this.observedIceCandidatesPair.set(stats.id, observedIceCandidatePair); + this.emit('added-ice-candidate-pair', observedIceCandidatePair); + } else { + observedIceCandidatePair.update(stats); + } + this.emit('updated-ice-candidate-pair', observedIceCandidatePair); + + return observedIceCandidatePair; + } - sumRttInMs += (track.rttInMs ?? 0); - sumJitter += (track.jitter ?? 0); + private _updateIceTransportStats(stats: IceCandidateStats) { + let observedIceTransport = this.observedIceTransports.get(stats.id); - track.score && trackScores.push(track.score); - }); + if (!observedIceTransport) { + if (!stats.timestamp || !stats.id) { + return logger.warn( + `ObservedPeerConnection received an invalid IceCandidateStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - this._inboundVideoTracks.forEach((track) => { - track.updateMetrics(); + observedIceTransport = new ObservedIceTransport(stats.timestamp, stats.id, this); - this.deltaInboundPacketsLost += track.deltaLostPackets; - this.deltaInboundPacketsReceived += track.deltaReceivedPackets; - this.deltaInboundReceivedBytes += track.deltaBytesReceived; - - this.deltaReceivedVideoBytes += track.deltaBytesReceived; - this.deltaReceivedVideoPackets += track.deltaReceivedPackets; + observedIceTransport.update(stats); - this.receivingVideoBitrate += track.bitrate; + this.observedIceTransports.set(stats.id, observedIceTransport); + this.emit('added-ice-transport', observedIceTransport); + } else { + observedIceTransport.update(stats); + } + this.emit('updated-ice-transport', observedIceTransport); - sumRttInMs += (track.rttInMs ?? 0); - sumJitter += (track.jitter ?? 0); + return observedIceTransport; + } - track.score && trackScores.push(track.score); - }); + private _updateInboundRtpStats(stats: InboundRtpStats) { + let observedInboundRtp = this.observedInboundRtps.get(stats.ssrc); - this._outboundAudioTracks.forEach((track) => { - track.updateMetrics(); + if (!observedInboundRtp) { + if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind || !stats.trackIdentifier) { + return logger.warn( + `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR ssrc OR kind OR trackIdentifier field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - this.deltaOutboundPacketsSent += track.deltaSentPackets; - this.deltaOutboundSentBytes += track.deltaSentBytes; + observedInboundRtp = new ObservedInboundRtp( + stats.timestamp, + stats.id, + stats.ssrc, + stats.kind as MediaKind, + stats.trackIdentifier, + this + ); - this.deltaSentAudioBytes += track.deltaSentBytes; - this.deltaSentAudioBytes += track.deltaSentPackets; - - this.sendingAudioBitrate += track.bitrate; + observedInboundRtp.update(stats); - sumRttInMs += (track.rttInMs ?? 0); - sumJitter += (track.jitter ?? 0); + this.observedInboundRtps.set(stats.ssrc, observedInboundRtp); + this.emit('added-inbound-rtp', observedInboundRtp); + } else { + observedInboundRtp.update(stats); + } + this.emit('updated-inbound-rtp', observedInboundRtp); - track.score && trackScores.push(track.score); - }); + return observedInboundRtp; + } - this._outboundVideoTracks.forEach((track) => { - track.updateMetrics(); + private _updateInboundTrackSample(stats: InboundTrackSample) { + let observedInboundTrack = this.observedInboundTracks.get(stats.id); - this.deltaOutboundPacketsSent += track.deltaSentPackets; - this.deltaOutboundSentBytes += track.deltaSentBytes; - - this.deltaSentVideoBytes += track.deltaSentBytes; - this.deltaSentVideoBytes += track.deltaSentPackets; + if (!observedInboundTrack) { + if (!stats.timestamp || !stats.id || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid InboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + const inboundRtp = [ ...this.observedInboundRtps.values() ].find((inbRtp) => inbRtp.trackIdentifier === stats.id); + const mediaPlayout = inboundRtp ? + [ ...this.observedMediaPlayouts.values() ].find((mp) => mp.id === inboundRtp.playoutId) : undefined; + + observedInboundTrack = new ObservedInboundTrack( + stats.timestamp, + stats.id, + stats.kind as MediaKind, + this, + inboundRtp, + mediaPlayout, + ); + + observedInboundTrack.update(stats); - this.sendingVideoBitrate += track.bitrate; + this.observedInboundTracks.set(stats.id, observedInboundTrack); + this.emit('added-inbound-track', observedInboundTrack); + } else { + observedInboundTrack.update(stats); + } + this.emit('updated-inbound-track', observedInboundTrack); - sumRttInMs += (track.rttInMs ?? 0); - sumJitter += (track.jitter ?? 0); + return observedInboundTrack; + } - track.score && trackScores.push(track.score); - }); + private _updateMediaPlayoutStats(stats: MediaPlayoutStats) { + let observedMediaPlayout = this.observedMediaPlayouts.get(stats.id); - this._dataChannels.forEach((channel) => { - this.deltaDataChannelBytesSent += channel.deltaBytesSent; - this.deltaDataChannelBytesReceived += channel.deltaBytesReceived; - }); + if (!observedMediaPlayout) { + if (!stats.timestamp || !stats.id || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR kind OR trackIdentifier field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } - const iceRttInMs = this.ICE.stats?.currentRoundTripTime; - let nrOfBelongings = this._inboundAudioTracks.size + this._inboundVideoTracks.size + this._outboundAudioTracks.size + this._outboundVideoTracks.size; + observedMediaPlayout = new ObservedMediaPlayout( + stats.timestamp, + stats.id, + stats.kind as MediaKind, + this + ); - this.avgJitter = 0 < nrOfBelongings ? sumJitter / nrOfBelongings : undefined; + observedMediaPlayout.update(stats); - if (iceRttInMs) { - sumRttInMs += iceRttInMs; - nrOfBelongings += 1; + this.observedMediaPlayouts.set(stats.id, observedMediaPlayout); + this.emit('added-media-playout', observedMediaPlayout); + } else { + observedMediaPlayout.update(stats); } + this.emit('updated-media-playout', observedMediaPlayout); - this.avgRttInMs = 0 < nrOfBelongings ? sumRttInMs / nrOfBelongings : undefined; - this.totalDataChannelBytesReceived += this.deltaDataChannelBytesReceived; - this.totalDataChannelBytesSent += this.deltaDataChannelBytesSent; - this.totalInboundPacketsLost += this.deltaInboundPacketsLost; - this.totalInboundPacketsReceived += this.deltaInboundPacketsReceived; - this.totalOutboundPacketsSent += this.deltaOutboundPacketsSent; - this.totalSentAudioBytes += this.deltaSentAudioBytes; - this.totalSentVideoBytes += this.deltaSentVideoBytes; - this.totalReceivedAudioBytes += this.deltaReceivedAudioBytes; - this.totalReceivedVideoBytes += this.deltaReceivedVideoBytes; - this.totalReceivedAudioPacktes += this.deltaReceivedAudioPackets; - this.totalReceivedVideoPackets += this.deltaReceivedVideoPackets; - this.totalSentAudioPackets += this.deltaSentAudioBytes; - this.totalSentVideoPackets += this.deltaSentVideoBytes; + return observedMediaPlayout; + } + + private _updateMediaSourceStats(stats: MediaSourceStats) { + let observedMediaSource = this.observedMediaSources.get(stats.id); + + if (!observedMediaSource) { + if (!stats.timestamp || !stats.id || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid InboundRtpStats (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedMediaSource = new ObservedMediaSource( + stats.timestamp, + stats.id, + stats.kind as MediaKind, + this + ); + + observedMediaSource.update(stats); + + this.observedMediaSources.set(stats.id, observedMediaSource); + this.emit('added-media-source', observedMediaSource); + } else { + observedMediaSource.update(stats); + } + this.emit('updated-media-source', observedMediaSource); + + return observedMediaSource; + } + + private _updateOutboundRtpStats(stats: OutboundRtpStats) { + let observedOutboundRtp = this.observedOutboundRtps.get(stats.ssrc); + + if (!observedOutboundRtp) { + if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid OutboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedOutboundRtp = new ObservedOutboundRtp( + stats.timestamp, + stats.id, + stats.ssrc, + stats.kind as MediaKind, + this + ); + + observedOutboundRtp.update(stats); + + this.observedOutboundRtps.set(stats.ssrc, observedOutboundRtp); + this.emit('added-outbound-rtp', observedOutboundRtp); + } else { + observedOutboundRtp.update(stats); + } + this.emit('updated-outbound-rtp', observedOutboundRtp); + + return observedOutboundRtp; + } + + public _updateOutboundTrackSample(stats: OutboundTrackSample) { + let observedOutboundTrack = this.observedOutboundTracks.get(stats.id); - if (this._elapsedTimeSinceLastUpdate && 0 < this._elapsedTimeSinceLastUpdate) { - this.sendingPacketsPerSecond = this.deltaOutboundPacketsSent / (this._elapsedTimeSinceLastUpdate / 1000); - this.receivingPacketsPerSecond = this.deltaInboundPacketsReceived / (this._elapsedTimeSinceLastUpdate / 1000); + if (!observedOutboundTrack) { + if (!stats.timestamp || !stats.id || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid OutboundTrackSample (missing timestamp OR id OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + const observedMediaSource = [ ...this.observedMediaSources.values() ].find((mediaSource) => mediaSource.trackIdentifier === stats.id); + const outboundRtps = observedMediaSource + ? [ ...this.observedOutboundRtps.values() ].filter((outboundRtp) => outboundRtp.mediaSourceId === observedMediaSource?.id) : undefined; + + observedOutboundTrack = new ObservedOutboundTrack( + stats.timestamp, + stats.id, + stats.kind as MediaKind, + this, + outboundRtps, + observedMediaSource, + ); + + observedOutboundTrack.update(stats); + + this.observedOutboundTracks.set(stats.id, observedOutboundTrack); + this.emit('added-outbound-track', observedOutboundTrack); } else { - this.sendingPacketsPerSecond = 0; - this.receivingPacketsPerSecond = 0; - } - - // calculate quality score - this._updateQualityScore(trackScores); - } - - private _updateQualityScore(trackScores: CalculatedScore[]) { - // Packet Jitter measured in seconds - // we use RTT and lost packets to calculate the base score for the connection - const rttInMs = this.avgRttInMs ?? 0; - const latencyFactor = rttInMs < 150 ? 1.0 : getRttScore(rttInMs); - const totalPackets = Math.max(1, (this.totalInboundPacketsReceived ?? 0) + (this.totalOutboundPacketsSent ?? 0)); - const lostPackets = (this.totalInboundPacketsLost ?? 0) + (this.deltaOutboundPacketsSent ?? 0); - const deliveryFactor = 1.0 - ((lostPackets) / (lostPackets + totalPackets)); - // let's push the actual stability score - const stabilityScore = ((latencyFactor * 0.5) + (deliveryFactor * 0.5)) ** 2; - - this._stabilityScores.push(stabilityScore); - if (10 < this._stabilityScores.length) { - this._stabilityScores.shift(); - } else if (this._stabilityScores.length < 5) { - return; - } - let counter = 0; - let weight = 0; - let totalScore = 0; - - for (const score of this._stabilityScores) { - weight += 1; - counter += weight; - totalScore += weight * score; - } - const weightedStabilityScore = totalScore / counter; - let sumTrackScores = 0; - - for (const trackScore of trackScores) { - sumTrackScores += trackScore.score; - } - - const avgTrackScores = sumTrackScores / trackScores.length; - - const score: CalculatedScore = { - remarks: [ { - severity: 'none', - text: `Peer Connection stability score: ${weightedStabilityScore}`, - }, - { - severity: 'none', - text: `Avg. Track score: ${avgTrackScores}`, - } ], - score: Math.round(((weightedStabilityScore + avgTrackScores) * 5 * 100)) / 100.0, - timestamp: this._statsTimestamp ?? Date.now(), - }; - - for (const pendingIssue of this.ωpendingIssuesForScores) { - switch (pendingIssue.severity) { - case 'critical': - score.score = 0.0; - break; - case 'major': - score.score *= 0.5; - break; - case 'minor': - score.score *= 0.8; - break; - } - score.remarks.push({ - severity: pendingIssue.severity, - text: pendingIssue.description ?? 'Issue occurred', - }); - } - - this.ωpendingIssuesForScores = []; - this.score = score; - - this.emit('score', score); - } -} \ No newline at end of file + observedOutboundTrack.update(stats); + } + this.emit('updated-outbound-track', observedOutboundTrack); + + return observedOutboundTrack; + } + + private _updatePeerConnectionTransportStats(stats: PeerConnectionTransportStats) { + let observedPeerConnectionTransport = this.observedPeerConnectionTransports.get(stats.id); + + if (!observedPeerConnectionTransport) { + if (!stats.timestamp || !stats.id) { + return logger.warn( + `ObservedPeerConnection received an invalid PeerConnectionTransportStats (missing timestamp OR id field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedPeerConnectionTransport = new ObservedPeerConnectionTransport(stats.timestamp, stats.id, this); + + observedPeerConnectionTransport.update(stats); + + this.observedPeerConnectionTransports.set(stats.id, observedPeerConnectionTransport); + this.emit('added-peer-connection-transport', observedPeerConnectionTransport); + } else { + observedPeerConnectionTransport.update(stats); + } + this.emit('updated-peer-connection-transport', observedPeerConnectionTransport); + + return observedPeerConnectionTransport; + } + + private _updateRemoteInboundRtpStats(stats: RemoteInboundRtpStats) { + let observedRemoteInboundRtp = this.observedRemoteInboundRtps.get(stats.ssrc); + + if (!observedRemoteInboundRtp) { + if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid RemoteInboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedRemoteInboundRtp = new ObservedRemoteInboundRtp( + stats.timestamp, + stats.id, + stats.ssrc, + stats.kind as MediaKind, + this + ); + + observedRemoteInboundRtp.update(stats); + + this.observedRemoteInboundRtps.set(stats.ssrc, observedRemoteInboundRtp); + this.emit('added-remote-inbound-rtp', observedRemoteInboundRtp); + } else { + observedRemoteInboundRtp.update(stats); + } + this.emit('updated-remote-inbound-rtp', observedRemoteInboundRtp); + + return observedRemoteInboundRtp; + } + + private _updateRemoteOutboundRtpStats(stats: RemoteOutboundRtpStats) { + let observedRemoteOutboundRtp = this.observedRemoteOutboundRtps.get(stats.ssrc); + + if (!observedRemoteOutboundRtp) { + if (!stats.timestamp || !stats.id || !stats.ssrc || !stats.kind) { + return logger.warn( + `ObservedPeerConnection received an invalid RemoteOutboundRtpStats (missing timestamp OR id OR ssrc OR kind field). PeerConnectionId: ${this.peerConnectionId} ClientId: ${this.client.clientId}, CallId: ${this.client.call.callId}`, + stats + ); + } + + observedRemoteOutboundRtp = new ObservedRemoteOutboundRtp( + stats.timestamp, + stats.id, + stats.ssrc, + stats.kind as MediaKind, + this + ); + + observedRemoteOutboundRtp.update(stats); + + this.observedRemoteOutboundRtps.set(stats.ssrc, observedRemoteOutboundRtp); + this.emit('added-remote-outbound-rtp', observedRemoteOutboundRtp); + } else { + observedRemoteOutboundRtp.update(stats); + } + this.emit('updated-remote-outbound-rtp', observedRemoteOutboundRtp); + + return observedRemoteOutboundRtp; + } +} diff --git a/src/ObservedPeerConnectionTransport.ts b/src/ObservedPeerConnectionTransport.ts new file mode 100644 index 0000000..8d8d0bf --- /dev/null +++ b/src/ObservedPeerConnectionTransport.ts @@ -0,0 +1,38 @@ +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { PeerConnectionTransportStats } from './schema/ClientSample'; + +export class ObservedPeerConnectionTransport implements PeerConnectionTransportStats { + private _visited = false; + public appData?: Record; + + dataChannelsOpened?: number | undefined; + dataChannelsClosed?: number | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public update(stats: PeerConnectionTransportStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.dataChannelsOpened = stats.dataChannelsOpened; + this.dataChannelsClosed = stats.dataChannelsClosed; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedRemoteInboundRtp.ts b/src/ObservedRemoteInboundRtp.ts new file mode 100644 index 0000000..f0237ec --- /dev/null +++ b/src/ObservedRemoteInboundRtp.ts @@ -0,0 +1,64 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { RemoteInboundRtpStats } from './schema/ClientSample'; + +export class ObservedRemoteInboundRtp implements RemoteInboundRtpStats { + private _visited = false; + public appData?: Record; + transportId?: string | undefined; + codecId?: string | undefined; + packetsReceived?: number | undefined; + packetsLost?: number | undefined; + jitter?: number | undefined; + localId?: string | undefined; + roundTripTime?: number | undefined; + totalRoundTripTime?: number | undefined; + fractionLost?: number | undefined; + roundTripTimeMeasurements?: number | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + public ssrc: number, + public kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getOutboundRtp() { + return this._peerConnection.observedOutboundRtps.get(this.ssrc); + } + + public getCodec() { + return this._peerConnection.observedCodecs.get(this.codecId ?? ''); + } + + public update(stats: RemoteInboundRtpStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.codecId = stats.codecId; + this.packetsReceived = stats.packetsReceived; + this.packetsLost = stats.packetsLost; + this.jitter = stats.jitter; + this.localId = stats.localId; + this.roundTripTime = stats.roundTripTime; + this.totalRoundTripTime = stats.totalRoundTripTime; + this.fractionLost = stats.fractionLost; + this.roundTripTimeMeasurements = stats.roundTripTimeMeasurements; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedRemoteOutboundRtp.ts b/src/ObservedRemoteOutboundRtp.ts new file mode 100644 index 0000000..204aa79 --- /dev/null +++ b/src/ObservedRemoteOutboundRtp.ts @@ -0,0 +1,65 @@ +import { MediaKind } from './common/types'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { RemoteOutboundRtpStats } from './schema/ClientSample'; + +export class ObservedRemoteOutboundRtp implements RemoteOutboundRtpStats { + private _visited = false; + public appData?: Record; + + transportId?: string | undefined; + codecId?: string | undefined; + packetsSent?: number | undefined; + bytesSent?: number | undefined; + localId?: string | undefined; + remoteTimestamp?: number | undefined; + reportsSent?: number | undefined; + roundTripTime?: number | undefined; + totalRoundTripTime?: number | undefined; + roundTripTimeMeasurements?: number | undefined; + attachments?: Record | undefined; + + public constructor( + public timestamp: number, + public id: string, + public ssrc: number, + public kind: MediaKind, + private readonly _peerConnection: ObservedPeerConnection + ) {} + + public get visited() { + const visited = this._visited; + + this._visited = false; + + return visited; + } + + public getPeerConnection() { + return this._peerConnection; + } + + public getInboundRtp() { + return this._peerConnection.observedInboundRtps.get(this.ssrc); + } + + public getCodec() { + return this._peerConnection.observedCodecs.get(this.codecId ?? ''); + } + + public update(stats: RemoteOutboundRtpStats) { + this._visited = true; + + this.timestamp = stats.timestamp; + this.transportId = stats.transportId; + this.codecId = stats.codecId; + this.packetsSent = stats.packetsSent; + this.bytesSent = stats.bytesSent; + this.localId = stats.localId; + this.remoteTimestamp = stats.remoteTimestamp; + this.reportsSent = stats.reportsSent; + this.roundTripTime = stats.roundTripTime; + this.totalRoundTripTime = stats.totalRoundTripTime; + this.roundTripTimeMeasurements = stats.roundTripTimeMeasurements; + this.attachments = stats.attachments; + } +} diff --git a/src/ObservedSfu.ts b/src/ObservedSfu.ts deleted file mode 100644 index c705383..0000000 --- a/src/ObservedSfu.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { EventEmitter } from 'events'; -import { Observer } from './Observer'; -import { SfuSample } from '@observertc/sample-schemas-js'; -import { ObservedSfuTransport, ObservedSfuTransportModel } from './ObservedSfuTransport'; -import { SfuEventReport, SfuExtensionReport } from '@observertc/report-schemas-js'; -import { PartialBy } from './common/utils'; - -export type ObservedSfuModel= { - serviceId: string; - mediaUnitId: string; - sfuId: string; - joined?: number; -}; - -export type ObservedSfuEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], - newtransport: [ObservedSfuTransport], -}; - -export declare interface ObservedSfu = Record> { - on(event: U, listener: (...args: ObservedSfuEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedSfuEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedSfuEvents[U]) => void): this; - emit(event: U, ...args: ObservedSfuEvents[U]): boolean; - - readonly appData: AppData; - update(sample: SfuSample): void; -} - -export class ObservedSfu = Record> extends EventEmitter { - public readonly created = Date.now(); - - private _updated = Date.now(); - private _closed = false; - private _marker?: string; - private _timeZoneOffsetInHours?: number; - private _left?: number; - - private readonly _transports = new Map(); - - public constructor( - private readonly _model: ObservedSfuModel, - public readonly observer: Observer, - public readonly appData: AppData, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get marker() { - return this._marker; - } - - public get reports() { - return this.observer.reports; - } - - public get serviceId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.serviceId!; - } - - public get mediaUnitId() { - return this._model.mediaUnitId; - } - - public get sfuId() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this._model.sfuId!; - } - - public get joined() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return Number(this._model.joined!); - } - - public get transports(): ReadonlyMap { - return this._transports; - } - - public get updated() { - return this._updated; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - [ ...this._transports.values() ].forEach((transport) => transport.close()); - - this.emit('close'); - } - - public addEventReport(params: PartialBy, 'timestamp'> & { attachments?: Record }) { - const { - attachments, - ...fields - } = params; - - this.reports.addCallEventReport({ - ...fields, - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - marker: this.marker, - timestamp: params.timestamp ?? Date.now(), - attachments: attachments ? JSON.stringify(attachments) : undefined, - }); - } - - public addExtensionStatsReport(extensionType: string, payload?: Record) { - this.reports.addSfuExtensionReport({ - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - timestamp: Date.now(), - extensionType, - payload: payload ? JSON.stringify(payload) : undefined, - marker: this.marker, - sfuId: this.sfuId, - }); - } - - public update(sample: SfuSample) { - if (this._closed) throw new Error(`Sfu ${this.sfuId} is closed`); - if (sample.sfuId !== this.sfuId) throw new Error(`Sfu ${this.sfuId} is not the same as sample.sfuId`); - - if (!this._timeZoneOffsetInHours && sample.timeZoneOffsetInHours) { - this._timeZoneOffsetInHours = sample.timeZoneOffsetInHours; - } - - this._marker = sample.marker; - - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - - for (const customSfuEvents of sample.customSfuEvents ?? []) { - const report: SfuEventReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - ...customSfuEvents, - timestamp: sample.timestamp, - marker: this._marker, - }; - - this.reports.addSfuEventReport(report); - } - - for (const extensionStats of sample.extensionStats ?? []) { - - const report: SfuExtensionReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - extensionType: extensionStats.type, - payload: extensionStats.payload, - timestamp: sample.timestamp, - }; - - this.reports.addSfuExtensionReport(report); - } - - for (const transport of sample.transports ?? []) { - const observedSfuTransport = this._transports.get(transport.transportId) ?? this._createTransport({ - sfuTransportId: transport.transportId, - }); - - observedSfuTransport.update(transport, sample.timestamp); - } - - for (const inboundRtpPad of sample.inboundRtpPads ?? []) { - const transport = this._transports.get(inboundRtpPad.transportId) ?? this._createTransport({ - sfuTransportId: inboundRtpPad.transportId, - }); - - const observedSfuInboundRtpPad = transport.inboundRtpPads.get(inboundRtpPad.padId) ?? transport.createSfuInboundRtpPad({ - sfuInboundRtpPadId: inboundRtpPad.padId, - }); - - observedSfuInboundRtpPad.update(inboundRtpPad, sample.timestamp); - } - - for (const outboundRtpPad of sample.outboundRtpPads ?? []) { - const transport = this._transports.get(outboundRtpPad.transportId) ?? this._createTransport({ - sfuTransportId: outboundRtpPad.transportId, - }); - - const observedSfuOutboundRtpPad = transport.outboundRtpPads.get(outboundRtpPad.padId) ?? transport.createSfuOutboundRtpPad({ - sfuOutboundRtpPadId: outboundRtpPad.padId, - }); - - observedSfuOutboundRtpPad.update(outboundRtpPad, sample.timestamp); - } - - for (const sctpChannel of sample.sctpChannels ?? []) { - const transport = this._transports.get(sctpChannel.transportId) ?? this._createTransport({ - sfuTransportId: sctpChannel.transportId, - }); - - const observedSfuSctpChannel = transport.sctpChannels.get(sctpChannel.channelId) ?? transport.createSfuSctpChannel({ - sfuSctpChannelId: sctpChannel.channelId, - }); - - observedSfuSctpChannel.update(sctpChannel, sample.timestamp); - } - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } - - private _createTransport(model: ObservedSfuTransportModel) { - const result = new ObservedSfuTransport(model, this); - - result.once('close', () => { - this._transports.delete(model.sfuTransportId); - }); - this._transports.set(model.sfuTransportId, result); - - this.emit('newtransport', result); - - return result; - } -} diff --git a/src/ObservedSfuInboundRtpPad.ts b/src/ObservedSfuInboundRtpPad.ts deleted file mode 100644 index ceca306..0000000 --- a/src/ObservedSfuInboundRtpPad.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { EventEmitter } from 'events'; -import { SfuInboundRtpPad } from '@observertc/sample-schemas-js'; -import { ObservedSfuTransport } from './ObservedSfuTransport'; -import { SfuInboundRtpPadReport } from '@observertc/report-schemas-js'; - -export type ObservedSfuInboundRtpPadModel= { - sfuInboundRtpPadId: string; -}; - -export type ObservedSfuInboundRtpPadEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], -}; - -export declare interface ObservedSfuInboundRtpPad { - on(event: U, listener: (...args: ObservedSfuInboundRtpPadEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedSfuInboundRtpPadEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedSfuInboundRtpPadEvents[U]) => void): this; - emit(event: U, ...args: ObservedSfuInboundRtpPadEvents[U]): boolean; - - update(sample: SfuInboundRtpPad, timestamp: number): void; -} - -export class ObservedSfuInboundRtpPad extends EventEmitter { - public readonly created = Date.now(); - - public stats?: SfuInboundRtpPad; - public marker?: string; - private _updated = Date.now(); - private _closed = false; - - public constructor( - private readonly _model: ObservedSfuInboundRtpPadModel, - public readonly transport: ObservedSfuTransport, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get reports() { - return this.transport.reports; - } - - public get serviceId() { - return this.transport.serviceId; - } - - public get mediaUnitId() { - return this.transport.mediaUnitId; - } - - public get sfuId() { - return this.transport.sfuId; - } - - public get sfuTransportId() { - return this.transport.sfuTransportId; - } - - public get sfuRtpPadId() { - return this._model.sfuInboundRtpPadId; - } - - public get updated() { - return this._updated; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - this.emit('close'); - } - - public update(sample: SfuInboundRtpPad, timestamp: number) { - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - - const { streamId: sfuStreamId, padId: rtpPadId, ...reportData } = sample; - - const report: SfuInboundRtpPadReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - callId: undefined, // TODO: where to get this from? - sfuStreamId, - rtpPadId, - ...reportData, - timestamp, - marker: this.marker, - }; - - this.reports.addSfuInboundRtpPadReport(report); - this.stats = sample; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } -} diff --git a/src/ObservedSfuOutboundRtpPad.ts b/src/ObservedSfuOutboundRtpPad.ts deleted file mode 100644 index 47a28d6..0000000 --- a/src/ObservedSfuOutboundRtpPad.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EventEmitter } from 'events'; -import { SfuOutboundRtpPad } from '@observertc/sample-schemas-js'; -import { SfuOutboundRtpPadReport } from '@observertc/report-schemas-js'; -import { ObservedSfuTransport } from './ObservedSfuTransport'; - -export type ObservedSfuOutboundRtpPadModel= { - sfuOutboundRtpPadId: string; -}; - -export type ObservedSfuOutboundRtpPadEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], -}; - -export declare interface ObservedSfuOutboundRtpPad { - on(event: U, listener: (...args: ObservedSfuOutboundRtpPadEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedSfuOutboundRtpPadEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedSfuOutboundRtpPadEvents[U]) => void): this; - emit(event: U, ...args: ObservedSfuOutboundRtpPadEvents[U]): boolean; - - update(sample: SfuOutboundRtpPad, timestamp: number): void; -} - -export class ObservedSfuOutboundRtpPad extends EventEmitter { - public readonly created = Date.now(); - - public stats?: SfuOutboundRtpPad; - public marker?: string; - private _updated = Date.now(); - private _closed = false; - - public constructor( - private readonly _model: ObservedSfuOutboundRtpPadModel, - public readonly transport: ObservedSfuTransport, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get reports() { - return this.transport.reports; - } - - public get serviceId() { - return this.transport.serviceId; - } - - public get mediaUnitId() { - return this.transport.mediaUnitId; - } - - public get sfuId() { - return this.transport.sfuId; - } - - public get sfuTransportId() { - return this.transport.sfuTransportId; - } - - public get sfuRtpPadId() { - return this._model.sfuOutboundRtpPadId; - } - - public get updated() { - return this._updated; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - this.emit('close'); - } - - public update(sample: SfuOutboundRtpPad, timestamp: number) { - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - - const { streamId: sfuStreamId, padId: rtpPadId, sinkId: sfuSinkId, ...reportData } = sample; - - const report: SfuOutboundRtpPadReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - callId: undefined, // TODO: where to get this from? - sfuStreamId, - sfuSinkId, - rtpPadId, - ...reportData, - timestamp, - marker: this.marker, - }; - - this.reports.addSfuOutboundRtpPadReport(report); - this.stats = sample; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } -} diff --git a/src/ObservedSfuSctpChannel.ts b/src/ObservedSfuSctpChannel.ts deleted file mode 100644 index c54406c..0000000 --- a/src/ObservedSfuSctpChannel.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { EventEmitter } from 'events'; -import { SfuSctpChannel } from '@observertc/sample-schemas-js'; -import { ObservedSfuTransport } from './ObservedSfuTransport'; -import { SfuSctpStreamReport } from '@observertc/report-schemas-js'; - -export type ObservedSfuSctpChannelModel= { - sfuSctpChannelId: string; -}; - -export type ObservedSfuSctpChannelEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], -}; - -export declare interface ObservedSfuSctpChannel { - on(event: U, listener: (...args: ObservedSfuSctpChannelEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedSfuSctpChannelEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedSfuSctpChannelEvents[U]) => void): this; - emit(event: U, ...args: ObservedSfuSctpChannelEvents[U]): boolean; - - update(sample: SfuSctpChannel, timestamp: number): void; -} - -export class ObservedSfuSctpChannel extends EventEmitter { - public readonly created = Date.now(); - - public stats?: SfuSctpChannel; - public marker?: string; - private _updated = Date.now(); - private _closed = false; - - public constructor( - private readonly _model: ObservedSfuSctpChannelModel, - public readonly transport: ObservedSfuTransport, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get serviceId() { - return this.transport.serviceId; - } - - public get sfuId() { - return this.transport.sfuId; - } - - public get mediaUnitId() { - return this.transport.mediaUnitId; - } - - public get sfuTransportId() { - return this.transport.sfuTransportId; - } - - public get sfuSctpChannelId() { - return this._model.sfuSctpChannelId; - } - - public get reports() { - return this.transport.reports; - } - - public get updated() { - return this._updated; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - this.emit('close'); - } - - public update(sample: SfuSctpChannel, timestamp: number) { - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - - const report: SfuSctpStreamReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - callId: undefined, // TODO: where to get this from? - roomId: undefined, // TODO: where to get this from? - ...sample, - timestamp, - marker: this.marker, - }; - - this.reports.addSfuTransportReport(report); - this.stats = sample; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } -} diff --git a/src/ObservedSfuTransport.ts b/src/ObservedSfuTransport.ts deleted file mode 100644 index b480dd4..0000000 --- a/src/ObservedSfuTransport.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { EventEmitter } from 'events'; -import { SfuTransport } from '@observertc/sample-schemas-js'; -import { ObservedSfu } from './ObservedSfu'; -import { SFUTransportReport } from '@observertc/report-schemas-js'; -import { ObservedSfuInboundRtpPad, ObservedSfuInboundRtpPadModel } from './ObservedSfuInboundRtpPad'; -import { ObservedSfuOutboundRtpPad, ObservedSfuOutboundRtpPadModel } from './ObservedSfuOutboundRtpPad'; -import { ObservedSfuSctpChannel, ObservedSfuSctpChannelModel } from './ObservedSfuSctpChannel'; - -export type ObservedSfuTransportModel= { - sfuTransportId: string; -}; - -export type ObservedSfuTransportEvents = { - update: [{ - elapsedTimeInMs: number; - }], - close: [], - newsfuoutboundrtppad: [ObservedSfuOutboundRtpPad], - newsfuinboundrtppad: [ObservedSfuInboundRtpPad], - newsfusctpchannel: [ObservedSfuSctpChannel], -}; - -export declare interface ObservedSfuTransport { - on(event: U, listener: (...args: ObservedSfuTransportEvents[U]) => void): this; - off(event: U, listener: (...args: ObservedSfuTransportEvents[U]) => void): this; - once(event: U, listener: (...args: ObservedSfuTransportEvents[U]) => void): this; - emit(event: U, ...args: ObservedSfuTransportEvents[U]): boolean; - - update(sample: SfuTransport, timestamp: number): void; -} - -export class ObservedSfuTransport extends EventEmitter { - public readonly created = Date.now(); - - public stats?: SfuTransport; - private _updated = Date.now(); - private _closed = false; - private _marker?: string; - - private readonly _inboundRtpPads = new Map(); - private readonly _outboundRtpPads = new Map(); - private readonly _sctpChannels = new Map(); - - public constructor( - private readonly _model: ObservedSfuTransportModel, - public readonly sfu: ObservedSfu, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public get inboundRtpPads(): ReadonlyMap { - return this._inboundRtpPads; - } - - public get outboundRtpPads(): ReadonlyMap { - return this._outboundRtpPads; - } - - public get sctpChannels(): ReadonlyMap { - return this._sctpChannels; - } - - public get serviceId() { - return this.sfu.serviceId; - } - - public get mediaUnitId() { - return this.sfu.mediaUnitId; - } - - public get sfuId() { - return this.sfu.sfuId; - } - - public get sfuTransportId() { - return this._model.sfuTransportId; - } - - public get reports() { - return this.sfu.reports; - } - - public get updated() { - return this._updated; - } - - public get marker() { - return this._marker; - } - - public set marker(value: string | undefined) { - this._marker = value; - this._inboundRtpPads.forEach((pad) => (pad.marker = value)); - this._outboundRtpPads.forEach((pad) => (pad.marker = value)); - this._sctpChannels.forEach((channel) => (channel.marker = value)); - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - [ ...this._inboundRtpPads.values() ].forEach((pad) => pad.close()); - [ ...this._outboundRtpPads.values() ].forEach((pad) => pad.close()); - [ ...this._sctpChannels.values() ].forEach((channel) => channel.close()); - - this.emit('close'); - } - - public update(sample: SfuTransport, timestamp: number) { - const now = Date.now(); - const elapsedTimeInMs = now - this._updated; - - const report: SFUTransportReport = { - serviceId: this.serviceId, - mediaUnitId: this.mediaUnitId, - sfuId: this.sfuId, - callId: undefined, // TODO: where to get this from? - roomId: undefined, // TODO: where to get this from? - ...sample, - timestamp, - marker: this.marker, - }; - - this.reports.addSfuTransportReport(report); - this.stats = sample; - - this._updated = now; - this.emit('update', { - elapsedTimeInMs, - }); - } - - public createSfuOutboundRtpPad(model: ObservedSfuOutboundRtpPadModel) { - if (this._closed) throw new Error('Cannot create SfuOutboundRtpPad on closed SfuTransport'); - if (this._inboundRtpPads.has(model.sfuOutboundRtpPadId)) throw new Error(`SfuOutboundRtpPad with id ${model.sfuOutboundRtpPadId} already exists`); - - const result = new ObservedSfuOutboundRtpPad(model, this); - - result.once('close', () => { - this._outboundRtpPads.delete(model.sfuOutboundRtpPadId); - }); - this._outboundRtpPads.set(model.sfuOutboundRtpPadId, result); - - this.emit('newsfuoutboundrtppad', result); - - return result; - } - - public createSfuInboundRtpPad(model: ObservedSfuInboundRtpPadModel) { - if (this._closed) throw new Error('Cannot create SfuInboundRtpPad on closed SfuTransport'); - if (this._inboundRtpPads.has(model.sfuInboundRtpPadId)) throw new Error(`SfuInboundRtpPad with id ${model.sfuInboundRtpPadId} already exists`); - - const result = new ObservedSfuInboundRtpPad(model, this); - - result.once('close', () => { - this._inboundRtpPads.delete(model.sfuInboundRtpPadId); - }); - this._inboundRtpPads.set(model.sfuInboundRtpPadId, result); - - this.emit('newsfuinboundrtppad', result); - - return result; - } - - public createSfuSctpChannel(model: ObservedSfuSctpChannelModel) { - if (this._closed) throw new Error('Cannot create SfuSctpChannel on closed SfuTransport'); - if (this._sctpChannels.has(model.sfuSctpChannelId)) throw new Error(`SfuSctpChannel with id ${model.sfuSctpChannelId} already exists`); - - const result = new ObservedSfuSctpChannel(model, this); - - result.once('close', () => { - this._sctpChannels.delete(model.sfuSctpChannelId); - }); - this._sctpChannels.set(model.sfuSctpChannelId, result); - - this.emit('newsfusctpchannel', result); - - return result; - } -} diff --git a/src/ObservedTURN.ts b/src/ObservedTURN.ts new file mode 100644 index 0000000..7ded965 --- /dev/null +++ b/src/ObservedTURN.ts @@ -0,0 +1,86 @@ +import { EventEmitter } from 'stream'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ObservedTurnServer } from './ObservedTurnServer'; +import { createLogger } from './common/logger'; + +const logger = createLogger('ObservedTURN'); + +export type ObservedTURNEventMap = { + 'update': [ObservedTURN]; + 'close': []; +} + +export declare interface ObservedTURN { + on(event: U, listener: (...args: ObservedTURNEventMap[U]) => void): this; + off(event: U, listener: (...args: ObservedTURNEventMap[U]) => void): this; + once(event: U, listener: (...args: ObservedTURNEventMap[U]) => void): this; + emit(event: U, ...args: ObservedTURNEventMap[U]): boolean; +} + +export class ObservedTURN extends EventEmitter { + public totalBytesSent = 0; + public totalBytesReceived = 0; + public totalPacketsSent = 0; + public totalPacketsReceived = 0; + + public packetsSentPerSecond = 0; + public packetsReceivedPerSecond = 0; + public outboundBitrate = 0; + public inboundBitrate = 0; + public numberOfClients = 0; + + public readonly servers = new Map(); + + public constructor( + ) { + super(); + } + + public update() { + const clientIds = new Set(); + + this.packetsReceivedPerSecond = 0; + this.packetsSentPerSecond = 0; + this.outboundBitrate = 0; + this.inboundBitrate = 0; + + for (const server of this.servers.values()) { + server.update(); + server.observedPeerConnections.forEach((pc) => clientIds.add(pc.client.clientId)); + } + + this.numberOfClients = clientIds.size; + } + + public addPeerConnection(peerConnection: ObservedPeerConnection) { + const turnPairs = peerConnection.selectedIceCandidatePairs.filter((pair) => pair.getLocalCandidate()?.candidateType === 'relay' && pair.getRemoteCandidate()?.url?.startsWith('turn:')); + + if (turnPairs.length !== 1) { + return (logger.warn(`Expected exactly one TURN pair, but found for peerconnection ${peerConnection.peerConnectionId}`, turnPairs.length), undefined); + } + + const candidatePair = turnPairs[0]; + const rawUrl = candidatePair.getRemoteCandidate()?.url; + + if (!rawUrl) { + return (logger.warn(`No remote candidate URL found for peerconnection ${peerConnection.peerConnectionId}`), undefined); + } + + const turnUrl = new URL(rawUrl); + const turnServerUrl = `${turnUrl.protocol}//${turnUrl.hostname}:${turnUrl.port}`; + let turnServer = this.servers.get(turnServerUrl); + + if (!turnServer) { + turnServer = new ObservedTurnServer(turnServerUrl, this); + } + + return turnServer; + } + + public removePeerConnection(peerConnection: ObservedPeerConnection) { + for (const turnServer of this.servers.values()) { + turnServer.observedPeerConnections.delete(peerConnection.peerConnectionId); + } + } + +} \ No newline at end of file diff --git a/src/ObservedTurnServer.ts b/src/ObservedTurnServer.ts new file mode 100644 index 0000000..e6cefe5 --- /dev/null +++ b/src/ObservedTurnServer.ts @@ -0,0 +1,72 @@ +import { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { ObservedTURN } from './ObservedTURN'; + +export class ObservedTurnServer { + public totalBytesSent = 0; + public totalBytesReceived = 0; + public totalPacketsSent = 0; + public totalPacketsReceived = 0; + + public packetsSentPerSecond = 0; + public packetsReceivedPerSecond = 0; + public outboundBitrate = 0; + public inboundBitrate = 0; + + public deltaBytesSent = 0; + public deltaBytesReceived = 0; + public deltaPacketsSent = 0; + public deltaPacketsReceived = 0; + + public readonly observedPeerConnections = new Map(); + + public updated = Date.now(); + + public constructor( + public readonly url: string, + public readonly observedTURN: ObservedTURN, + ) { + + } + + public updateTurnUsage(...selectedcandidatepairs: ObservedIceCandidatePair[]) { + for (const selectedcandidatepair of selectedcandidatepairs) { + this.deltaBytesReceived += selectedcandidatepair.deltaBytesReceived; + this.deltaBytesSent += selectedcandidatepair.deltaBytesSent; + this.deltaPacketsReceived += selectedcandidatepair.deltaPacketsReceived; + this.deltaPacketsSent += selectedcandidatepair.deltaPacketsSent; + } + } + + public update() { + const now = Date.now(); + const deltaInS = (now - this.updated) / 1000; + + this.packetsSentPerSecond = this.deltaPacketsSent / deltaInS; + this.packetsReceivedPerSecond = this.deltaPacketsReceived / deltaInS; + this.outboundBitrate = (this.deltaBytesReceived * 8) / deltaInS; + this.inboundBitrate = (this.deltaBytesSent * 8) / deltaInS; + + this.totalBytesSent += this.deltaBytesSent; + this.totalBytesReceived += this.deltaBytesReceived; + this.totalPacketsSent += this.deltaPacketsSent; + this.totalPacketsReceived += this.deltaPacketsReceived; + + this.observedTURN.totalBytesSent += this.deltaBytesSent; + this.observedTURN.totalBytesReceived += this.deltaBytesReceived; + this.observedTURN.totalPacketsSent += this.deltaPacketsSent; + this.observedTURN.totalPacketsReceived += this.deltaPacketsReceived; + + this.observedTURN.inboundBitrate += this.inboundBitrate; + this.observedTURN.outboundBitrate += this.outboundBitrate; + this.observedTURN.packetsReceivedPerSecond += this.packetsReceivedPerSecond; + this.observedTURN.packetsSentPerSecond += this.packetsSentPerSecond; + + this.deltaBytesSent = 0; + this.deltaBytesReceived = 0; + this.deltaPacketsSent = 0; + this.deltaPacketsReceived = 0; + + this.updated = now; + } +} \ No newline at end of file diff --git a/src/Observer.ts b/src/Observer.ts index f1d9c82..7d80049 100644 --- a/src/Observer.ts +++ b/src/Observer.ts @@ -1,48 +1,37 @@ import { createLogger } from './common/logger'; -import { ObservedCall, ObservedCallModel } from './ObservedCall'; -import { ReportsCollector } from './ReportsCollector'; +import { ObservedCall, ObservedCallSettings } from './ObservedCall'; import { EventEmitter } from 'events'; -import { PartialBy } from './common/utils'; -import { createCallEndedEventReport, createCallStartedEventReport } from './common/callEventReports'; -import { ObserverSinkContext } from './common/types'; -import { ObservedSfu, ObservedSfuModel } from './ObservedSfu'; -import { CallSummaryMonitor, CallSummaryMonitorConfig } from './monitors/CallSummaryMonitor'; -import { TurnUsageMonitor } from './monitors/TurnUsageMonitor'; +import { ClientEvent, ClientMetaData, ClientIssue, ExtensionStat, ClientSample } from './schema/ClientSample'; import { ObservedClient } from './ObservedClient'; -import { ObservedPeerConnection } from './ObservedPeerConnection'; -import { ClientIssueMonitor } from './monitors/ClientIssueMonitor'; -import { SfuServerMonitor } from './monitors/SfuServerMonitor'; +import { ObservedTURN } from './ObservedTURN'; +import { Detectors } from './detectors/Detectors'; +import { Updater } from './updaters/Updater'; +import { OnIntervalUpdater } from './updaters/OnIntervalUpdater'; +import { OnAllCallObserverUpdater } from './updaters/OnAllCallObserverUpdater'; +import { OnAnyCallObserverUpdater } from './updaters/OnAnyCallObserverUpdater'; +import { ObserverEventMonitor } from './ObserverEventMonitor'; +import { MediasoupRemoteTrackResolver } from './utils/MediasoupRemoteTrackResolver'; const logger = createLogger('Observer'); export type ObserverEvents = { + 'client-event': [ObservedClient, ClientEvent]; + 'call-updated': [ObservedCall], + 'client-issue': [ObservedClient, ClientIssue]; + 'client-metadata': [ObservedClient, ClientMetaData]; + 'client-extension-stats': [ObservedClient, ExtensionStat]; 'newcall': [ObservedCall], - 'newsfu': [ObservedSfu], - 'reports': [ObserverSinkContext], + 'update': [], 'close': [], } -export type ObserverConfig = { - - /** - * - * Sets the default serviceId for samples. - * - * For more information about a serviceId please visit https://observertc.org - * - */ - defaultServiceId: string; - - /** - * Sets the default mediaUnitId for samples. - * - * For more information about a mediaUnitId please visit https://observertc.org - */ - defaultMediaUnitId: string; - - maxReports?: number | undefined; - maxCollectingTimeInMs?: number | undefined; -}; +export type ObserverConfig = Record> = { + updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'update-on-interval', + updateIntervalInMs?: number, + defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'], + defaultCallUpdateIntervalInMs?: number, + appData?: AppData, +} export declare interface Observer { on(event: U, listener: (...args: ObserverEvents[U]) => void): this; @@ -51,313 +40,208 @@ export declare interface Observer { emit(event: U, ...args: ObserverEvents[U]): boolean; } -export class Observer extends EventEmitter { - public static create(providedConfig: Partial): Observer { - const config: ObserverConfig = Object.assign( - { - defaultServiceId: 'default-service-id', - defaultMediaUnitId: 'default-media-unit-id', - evaluator: { - fetchSamples: true, - maxIdleTimeInMs: 300 * 1000, - }, - sink: {}, - logLevel: 'info', - }, - providedConfig - ); - - return new Observer(config); +export class Observer = Record> extends EventEmitter { + public readonly detectors: Detectors; + + public readonly observedTURN = new ObservedTURN(); + public readonly observedCalls = new Map(); + public updater?: Updater; + + public closed = false; + + public totalAddedCall = 0; + public totalRemovedCall = 0; + public totalRttLt50Measurements = 0; + public totalRttLt150Measurements = 0; + public totalRttLt300Measurements = 0; + public totalRttGtOrEq300Measurements = 0; + public totalClientIssues = 0; + + public numberOfClientsUsingTurn = 0; + public numberOfClients = 0; + public numberOfInboundRtpStreams = 0; + public numberOfOutboundRtpStreams = 0; + public numberOfDataChannels = 0; + public numberOfPeerConnections = 0; + + public get numberOfCalls() { + return this.observedCalls.size; } - public readonly reports = new ReportsCollector(); - private readonly _observedCalls = new Map(); - private readonly _observedSfus = new Map(); - private readonly _monitors = new Map void, once: (e: 'close', l: () => void) => void }>(); + private _timer?: ReturnType; - private _reportTimer?: ReturnType; - private _closed = false; - public constructor( - public readonly config: ObserverConfig, - ) { + public constructor(public readonly config: ObserverConfig = { + updatePolicy: 'update-when-all-call-updated', + updateIntervalInMs: undefined, + appData: {} as AppData, + }) { super(); this.setMaxListeners(Infinity); - - logger.debug('Observer is created with config', this.config); - - const onReports = (context: ObserverSinkContext) => this.emit('reports', context); - const onNewReport = (collectedReports: number) => { - if (!this.config.maxReports || this._closed) return; - if (this.config.maxReports < collectedReports) { - this._emitReports(); + this.update = this.update.bind(this); + + const currentUpdatePolicy = (config?.updatePolicy) ?? 'update-when-all-call-updated'; + + switch (currentUpdatePolicy) { + case 'update-on-any-call-updated': + this.updater = new OnAnyCallObserverUpdater(this); + break; + case 'update-when-all-call-updated': + this.updater = new OnAllCallObserverUpdater(this); + break; + case 'update-on-interval': { + const interval = config?.updateIntervalInMs; + + if (!interval) { + throw new Error('updateIntervalInMs setting in config must be set if updatePolicy is update-on-interval'); + } + this.updater = new OnIntervalUpdater( + interval, + this.update.bind(this), + ); + break; } - }; + } + + this.detectors = new Detectors(); + } - this._emitReports(); + public get appData() { + return this.config.appData; + } - this.once('close', () => { - this.reports.off('newreport', onNewReport); - this.reports.off('reports', onReports); - }); - this.reports.on('newreport', onNewReport); - this.reports.on('reports', onReports); + public getObservedCall = Record>(callId: string): ObservedCall | undefined { + if (this.closed || !this.observedCalls.has(callId)) return; + + return this.observedCalls.get(callId) as ObservedCall; } public createObservedCall = Record>( - config: PartialBy & { appData: T, reportCallStarted?: boolean, reportCallEnded?: boolean } + settings: ObservedCallSettings ): ObservedCall { - if (this._closed) { + if (this.closed) { throw new Error('Attempted to create a call source on a closed observer'); } - - const { - appData, - reportCallEnded = true, - reportCallStarted = true, - ...model - } = config; - const call = new ObservedCall({ - ...model, - serviceId: this.config.defaultServiceId, - }, this, appData); - - if (this._closed) throw new Error('Cannot create an observed call on a closed observer'); - if (this._observedCalls.has(call.callId)) throw new Error(`Observed Call with id ${call.callId} already exists`); - - call.once('close', () => { - this._observedCalls.delete(call.callId); - reportCallEnded && this.reports.addCallEventReport(createCallEndedEventReport( - call.serviceId, - call.roomId, - call.callId, - call.observationEnded ?? Date.now(), - )); - }); - - this._observedCalls.set(call.callId, call); - reportCallStarted && this.reports.addCallEventReport(createCallStartedEventReport( - call.serviceId, - call.roomId, - call.callId, - call.observationStarted, - )); - - this.emit('newcall', call); - - return call; - } - - public createObservedSfu = Record>( - model: ObservedSfuModel, - appData: AppData, - ): ObservedSfu { - if (this._closed) { - throw new Error('Attempted to create an sfu source on a closed observer'); + if (!settings.updatePolicy) { + settings.updatePolicy = this.config.defaultCallUpdatePolicy; + settings.updateIntervalInMs = this.config.defaultCallUpdateIntervalInMs; } + const observedCall = new ObservedCall(settings, this); + const onCallUpdated = () => this._onObservedCallUpdated(observedCall); - const sfu = new ObservedSfu(model, this, appData); + if (this.observedCalls.has(observedCall.callId)) throw new Error(`Observed Call with id ${observedCall.callId} already exists`); - if (this._closed) throw new Error('Cannot create an observed sfu on a closed observer'); - if (this._observedSfus.has(sfu.sfuId)) throw new Error(`Observed SFU with id ${sfu.sfuId} already exists`); + if (settings.remoteTrackResolvePolicy === 'mediasoup-sfu') { + observedCall.remoteTrackResolver = new MediasoupRemoteTrackResolver(observedCall); + } - sfu.once('close', () => { - this._observedSfus.delete(sfu.sfuId); + observedCall.once('close', () => { + this.observedCalls.delete(observedCall.callId); + observedCall.off('update', onCallUpdated); + ++this.totalRemovedCall; }); - this._observedSfus.set(sfu.sfuId, sfu); - this.emit('newsfu', sfu); - - return sfu; - } - - public createCallSummaryMonitor(options?: CallSummaryMonitorConfig & { timeoutAfterCallClose?: number }): CallSummaryMonitor { - if (this._closed) throw new Error('Cannot create a call summary monitor on a closed observer'); - - const existingMonitor = this._monitors.get(CallSummaryMonitor.name); - - if (existingMonitor) return existingMonitor as CallSummaryMonitor; - - const monitor = new CallSummaryMonitor(options); - const onNewCall = (call: ObservedCall) => { - monitor.addCall(call); - - call.once('close', () => setTimeout(() => { - const summary = monitor.takeSummary(call.callId); + this.observedCalls.set(observedCall.callId, observedCall); + observedCall.on('update', onCallUpdated); + ++this.totalAddedCall; - summary && monitor.emit('summary', summary); - - }, options?.timeoutAfterCallClose ?? 1000)); - }; - - monitor.once('close', () => { - this._monitors.delete(CallSummaryMonitor.name); - this.off('newcall', onNewCall); - }); - - this._monitors.set(CallSummaryMonitor.name, monitor); - this.on('newcall', onNewCall); + this.emit('newcall', observedCall); - this.once('close', () => { - monitor.close(); - }); - - return monitor; + return observedCall; } - public createTurnUsageMonitor() { - if (this._closed) throw new Error('Cannot create a turn usage monitor on a closed observer'); - - const existingMonitor = this._monitors.get(TurnUsageMonitor.name); - - if (existingMonitor) return existingMonitor as TurnUsageMonitor; - - const monitor = new TurnUsageMonitor(); - - const onNewCall = (call: ObservedCall) => { - const onNewClient = (client: ObservedClient) => { - const onNewPeerConnection = (pc: ObservedPeerConnection) => { - const onUsingTurnChanged = (usingTurn: boolean) => { - if (usingTurn) monitor.addPeerConnection(pc); - else monitor.removePeerConnection(pc); - }; - - pc.once('close', () => { - pc.ICE.off('usingturnchanged', onUsingTurnChanged); - monitor.removePeerConnection(pc); - }); - pc.ICE.on('usingturnchanged', onUsingTurnChanged); - }; - - client.once('close', () => client.off('newpeerconnection', onNewPeerConnection)); - client.on('newpeerconnection', onNewPeerConnection); - }; - - call.once('close', () => call.off('newclient', onNewClient)); - call.on('newclient', onNewClient); - }; - - monitor.once('close', () => { - this._monitors.delete(TurnUsageMonitor.name); - this.off('newcall', onNewCall); - }); + public close() { + if (this.closed) { + return logger.debug('Attempted to close twice'); + } + this.closed = true; + clearInterval(this._timer); + this._timer = undefined; - this._monitors.set(TurnUsageMonitor.name, monitor); - this.on('newcall', onNewCall); + this.observedCalls.forEach((call) => call.close()); - this.once('close', () => { - monitor.close(); - }); - - return monitor; + this.emit('close'); } - public createSfuServerMonitor() { - if (this._closed) throw new Error('Cannot create a turn usage monitor on a closed observer'); - - const existingMonitor = this._monitors.get(SfuServerMonitor.name); - - if (existingMonitor) return existingMonitor as SfuServerMonitor; - - const monitor = new SfuServerMonitor({ - tooHighRttAlertSettings: { - minNumberOfPeerConnections: 10, - percentageOfPeerConnectionsHighWatermark: 0.5, - percentageOfPeerConnectionsLowWatermark: 0.2, - threshold: 'rtt-gt-300', - } - }); - - const onNewCall = (call: ObservedCall) => { - const onNewClient = (client: ObservedClient) => { - const onNewPeerConnection = (pc: ObservedPeerConnection) => { - monitor.addPeerConnection(pc); - }; - - client.once('close', () => client.off('newpeerconnection', onNewPeerConnection)); - client.on('newpeerconnection', onNewPeerConnection); - }; - - call.once('close', () => call.off('newclient', onNewClient)); - call.on('newclient', onNewClient); - }; + public accept(sample: ClientSample) { + if (this.closed) return; + if (!sample.callId) return logger.warn('Received sample without callId. %o', sample); + if (!sample.clientId) return logger.warn('Received sample without clientId %o', sample); - monitor.once('close', () => { - this._monitors.delete(SfuServerMonitor.name); - this.off('newcall', onNewCall); + const call = this.getObservedCall(sample.callId) ?? this.createObservedCall({ + callId: sample.callId, + updateIntervalInMs: this.config.defaultCallUpdateIntervalInMs, + updatePolicy: this.config.defaultCallUpdatePolicy, }); - this._monitors.set(SfuServerMonitor.name, monitor); - this.on('newcall', onNewCall); - - this.once('close', () => { - monitor.close(); + const client = call.getObservedClient(sample.clientId) ?? call.createObservedClient({ + clientId: sample.clientId, }); - return monitor; + client.accept(sample); } - public createClientIssueMonitor() { - if (this._closed) throw new Error('Cannot create a turn usage monitor on a closed observer'); - - const existingMonitor = this._monitors.get(ClientIssueMonitor.name); - - if (existingMonitor) return existingMonitor as ClientIssueMonitor; - - const monitor = new ClientIssueMonitor(); - - const onNewCall = (call: ObservedCall) => monitor.addCall(call); - - monitor.once('close', () => { - this._monitors.delete(ClientIssueMonitor.name); - this.off('newcall', onNewCall); - }); - - this._monitors.set(ClientIssueMonitor.name, monitor); - this.on('newcall', onNewCall); - - this.once('close', () => { - monitor.close(); - }); + public update() { + if (this.closed) { + return; + } - return monitor; - } + this.numberOfInboundRtpStreams = 0; + this.numberOfOutboundRtpStreams = 0; + this.numberOfPeerConnections = 0; + this.numberOfDataChannels = 0; + this.numberOfClients = 0; + this.numberOfClientsUsingTurn = 0; + + for (const call of this.observedCalls.values()) { + this.numberOfInboundRtpStreams += call.numberOfInboundRtpStreams; + this.numberOfOutboundRtpStreams += call.numberOfOutboundRtpStreams; + this.numberOfPeerConnections += call.numberOfPeerConnections; + this.numberOfDataChannels += call.numberOfDataChannels; + this.numberOfClients += call.numberOfClients; + this.numberOfClientsUsingTurn += call.clientsUsedTurn.size; + } - public get observedCalls(): ReadonlyMap { - return this._observedCalls; - } + this.observedTURN.update(); - public get observedSfus(): ReadonlyMap { - return this._observedSfus; + this.emit('update'); } - public get closed() { - return this._closed; + private _onObservedCallUpdated(call: ObservedCall) { + this.totalRttLt50Measurements += call.deltaRttLt50Measurements; + this.totalRttLt150Measurements += call.deltaRttLt150Measurements; + this.totalRttLt300Measurements += call.deltaRttLt300Measurements; + this.totalRttGtOrEq300Measurements += call.deltaRttGtOrEq300Measurements; + this.totalClientIssues += call.deltaNumberOfIssues; } - public close() { - if (this._closed) { - return logger.debug('Attempted to close twice'); - } - this._closed = true; - - this._observedCalls.forEach((call) => call.close()); - - this.emit('close'); + public createEventMonitor(ctx?: CTX): ObserverEventMonitor { + return new ObserverEventMonitor(this, ctx ?? {} as CTX); } - private _emitReports() { - if (this._reportTimer) { - clearTimeout(this._reportTimer); - this._reportTimer = undefined; - } - - this.reports.emit(); + // public resetSummaryMetrics() { + // this.totalAddedCall = 0; + // this.totalRemovedCall = 0; + // this.totalRttLt50Measurements = 0; + // this.totalRttLt150Measurements = 0; + // this.totalRttLt300Measurements = 0; + // this.totalRttGtOrEq300Measurements = 0; + // this.totalClientIssues = 0; + // } + + // public createSummary(): ObserverSummary { + // return { + // totalAddedCall: this.totalAddedCall, + // totalRemovedCall: this.totalRemovedCall, + // totalRttLt50Measurements: this.totalRttLt50Measurements, + // totalRttLt150Measurements: this.totalRttLt150Measurements, + // totalRttLt300Measurements: this.totalRttLt300Measurements, + // totalRttGtOrEq300Measurements: this.totalRttGtOrEq300Measurements, + + // totalClientIssues: this.totalClientIssues, + // currentActiveCalls: this.observedCalls.size, + // currentNumberOfClients: this.numberOfClients, + // currentNumberOfClientsUsingTURN: this.numberOfClientsUsedTurn, + // }; + // } - if (this.config.maxCollectingTimeInMs) { - this._reportTimer = setTimeout(() => { - this._reportTimer = undefined; - this._emitReports(); - }, this.config.maxCollectingTimeInMs); - } - } } diff --git a/src/ObserverEventMonitor.ts b/src/ObserverEventMonitor.ts new file mode 100644 index 0000000..b847903 --- /dev/null +++ b/src/ObserverEventMonitor.ts @@ -0,0 +1,551 @@ +import { ObservedCall } from './ObservedCall'; +import { ObservedCertificate } from './ObservedCertificate'; +import { ObservedClient, ObservedClientEvents } from './ObservedClient'; +import { ObservedCodec } from './ObservedCodec'; +import { ObservedDataChannel } from './ObservedDataChannel'; +import { ObservedIceCandidate } from './ObservedIceCandidate'; +import { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +import { ObservedIceTransport } from './ObservedIceTransport'; +import { ObservedInboundRtp } from './ObservedInboundRtp'; +import { ObservedInboundTrack } from './ObservedInboundTrack'; +import { ObservedMediaPlayout } from './ObservedMediaPlayout'; +import { ObservedMediaSource } from './ObservedMediaSource'; +import { ObservedOutboundRtp } from './ObservedOutboundRtp'; +import { ObservedOutboundTrack } from './ObservedOutboundTrack'; +import { ObservedPeerConnection } from './ObservedPeerConnection'; +import { Observer } from './Observer'; +import { ClientEvent, ClientIssue, ClientMetaData, ClientSample, ExtensionStat } from './schema/ClientSample'; + +export class ObserverEventMonitor { + public constructor( + public readonly observer: Observer, + public readonly context: Context, + ) { + this._onPeerConnconnectionAdded = this._onPeerConnconnectionAdded.bind(this); + this._onPeerConnectionRemoved = this._onPeerConnectionRemoved.bind(this); + this._onCertificateAdded = this._onCertificateAdded.bind(this); + this._onCertificateRemoved = this._onCertificateRemoved.bind(this); + this._onInboundTrackAdded = this._onInboundTrackAdded.bind(this); + this._onInboundTrackRemoved = this._onInboundTrackRemoved.bind(this); + this._onOutboundTrackAdded = this._onOutboundTrackAdded.bind(this); + this._onOutboundTrackRemoved = this._onOutboundTrackRemoved.bind(this); + this._onOutboundTrackMuted = this._onOutboundTrackMuted.bind(this); + this._onOutboundTrackUnmuted = this._onOutboundTrackUnmuted.bind(this); + this._onInboundRtpAdded = this._onInboundRtpAdded.bind(this); + this._onInboundRtpRemoved = this._onInboundRtpRemoved.bind(this); + this._onInboundRtpUpdated = this._onInboundRtpUpdated.bind(this); + this._onOutboundTrackUpdated = this._onOutboundTrackUpdated.bind(this); + this._onInboundTrackUpdated = this._onInboundTrackUpdated.bind(this); + this._onOutboundRtpAdded = this._onOutboundRtpAdded.bind(this); + this._onOutboundRtpRemoved = this._onOutboundRtpRemoved.bind(this); + this._onDataChannelAdded = this._onDataChannelAdded.bind(this); + this._onDataChannelRemoved = this._onDataChannelRemoved.bind(this); + this._onAddedIceTransport = this._onAddedIceTransport.bind(this); + this._onRemovedIceTransport = this._onRemovedIceTransport.bind(this); + this._onIceCandidateAdded = this._onIceCandidateAdded.bind(this); + this._onIceCandidateRemoved = this._onIceCandidateRemoved.bind(this); + this._onAddedIceCandidatePair = this._onAddedIceCandidatePair.bind(this); + this._onRemovedIceCandidatePair = this._onRemovedIceCandidatePair.bind(this); + this._onAddedMediaCodec = this._onAddedMediaCodec.bind(this); + this._onRemovedMediaCodec = this._onRemovedMediaCodec.bind(this); + this._onAddedMediaPlayout = this._onAddedMediaPlayout.bind(this); + this._onRemovedMediaPlayout = this._onRemovedMediaPlayout.bind(this); + this._onMediaSourceAdded = this._onMediaSourceAdded.bind(this); + this._onMediaSourceRemoved = this._onMediaSourceRemoved.bind(this); + this._onClientIssue = this._onClientIssue.bind(this); + this._onClientMetadata = this._onClientMetadata.bind(this); + this._onClientJoined = this._onClientJoined.bind(this); + this._onClientLeft = this._onClientLeft.bind(this); + this._onUserMediaError = this._onUserMediaError.bind(this); + this._onUsingTurn = this._onUsingTurn.bind(this); + this._onClientAdded = this._onClientAdded.bind(this); + this._onCallAdded = this._onCallAdded.bind(this); + this._onClientRejoined = this._onClientRejoined.bind(this); + this._onClientExtensionStats = this._onClientExtensionStats.bind(this); + this._onCertificateUpdated = this._onCertificateUpdated.bind(this); + this._onInboundRtpUpdated = this._onInboundRtpUpdated.bind(this); + this._onOutboundRtpUpdated = this._onOutboundRtpUpdated.bind(this); + this._onInboundTrackUpdated = this._onInboundTrackUpdated.bind(this); + this._onOutboundTrackUpdated = this._onOutboundTrackUpdated.bind(this); + this._onDataChannelUpdated = this._onDataChannelUpdated.bind(this); + this._onIceTransportUpdated = this._onIceTransportUpdated.bind(this); + this._onIceCandidatePairUpdated = this._onIceCandidatePairUpdated.bind(this); + this._onMediaCodecUpdated = this._onMediaCodecUpdated.bind(this); + this._onMediaPlayoutUpdated = this._onMediaPlayoutUpdated.bind(this); + this._onMediaSourceUpdated = this._onMediaSourceUpdated.bind(this); + this._onIceCandidateUpdated = this._onIceCandidateUpdated.bind(this); + + this.observer.once('close', () => { + this.observer.off('newcall', this._onCallAdded); + }); + this.observer.on('newcall', this._onCallAdded); + } + + // Public event handlers + public onCallAdded?: (call: ObservedCall, ctx: Context) => void; + public onCallRemoved?: (call: ObservedCall, ctx: Context) => void; + public onCallEmpty?: (call: ObservedCall, ctx: Context) => void; + public onCallNotEmpty?: (call: ObservedCall, ctx: Context) => void; + public onCallUpdated?: (call: ObservedCall, ctx: Context) => void; + + public onClientAdded?: (client: ObservedClient, ctx: Context) => void; + public onClientClosed?: (client: ObservedClient, ctx: Context) => void; + public onClientRejoined?: (client: ObservedClient, ctx: Context) => void; + public onClientIssue?: (observedClent: ObservedClient, issue: ClientIssue, ctx: Context) => void; + public onClientMetadata?: (observedClient: ObservedClient, metadata: ClientMetaData, ctx: Context) => void; + public onClientExtensionStats?: (observedClient: ObservedClient, extensionStats: ExtensionStat, ctx: Context) => void; + public onClientJoined?: (client: ObservedClient, ctx: Context) => void; + public onClientLeft?: (client: ObservedClient, ctx: Context) => void; + public onClientUserMediaError?: (observedClient: ObservedClient, error: string, ctx: Context) => void; + public onClientUsingTurn?: (client: ObservedClient, usingTurn: boolean, ctx: Context) => void; + public onClientUpdated?: (client: ObservedClient, sample: ClientSample, ctx: Context) => void; + public onClientEvent?: (client: ObservedClient, event: ClientEvent, ctx: Context) => void; + + public onPeerConnectionAdded?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onPeerConnectionRemoved?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onSelectedCandidatePairChanged?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onIceGatheringStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onIceConnectionStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + public onConnectionStateChange?: (peerConnection: ObservedPeerConnection, ctx: Context) => void; + + public onCertificateAdded?: (certificate: ObservedCertificate, ctx: Context) => void; + public onCertificateRemoved?: (certificate: ObservedCertificate, ctx: Context) => void; + public onCertificateUpdated?: (certificate: ObservedCertificate, ctx: Context) => void; + + public onInboundTrackAdded?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + public onInboundTrackRemoved?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + public onInboundTrackUpdated?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + public onInboundTrackMuted?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + public onInboundTrackUnmuted?: (inboundTrack: ObservedInboundTrack, ctx: Context) => void; + + public onOutboundTrackAdded?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + public onOutboundTrackRemoved?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + public onOutboundTrackUpdated?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + public onOutboundTrackMuted?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + public onOutboundTrackUnmuted?: (outboundTrack: ObservedOutboundTrack, ctx: Context) => void; + + public onInboundRtpAdded?: (inboundRtp: ObservedInboundRtp, ctx: Context) => void; + public onInboundRtpRemoved?: (inboundRtp: ObservedInboundRtp, ctx: Context) => void; + public onInboundRtpUpdated?: (inboundRtp: ObservedInboundRtp, ctx: Context) => void; + + public onOutboundRtpAdded?: (outboundRtp: ObservedOutboundRtp, ctx: Context) => void; + public onOutboundRtpRemoved?: (outboundRtp: ObservedOutboundRtp, ctx: Context) => void; + public onOutboundRtpUpdated?: (outboundRtp: ObservedOutboundRtp, ctx: Context) => void; + + public onDataChannelAdded?: (dataChannel: ObservedDataChannel, ctx: Context) => void; + public onDataChannelRemoved?: (dataChannel: ObservedDataChannel, ctx: Context) => void; + public onDataChannelUpdated?: (dataChannel: ObservedDataChannel, ctx: Context) => void; + + public onAddedIceTransport?: (iceTransport: ObservedIceTransport, ctx: Context) => void; + public onRemovedIceTransport?: (iceTransport: ObservedIceTransport, ctx: Context) => void; + public onIceTransportUpdated?: (iceTransport: ObservedIceTransport, ctx: Context) => void; + + public onIceCandidateAdded?: (iceCandidate: ObservedIceCandidate, ctx: Context) => void; + public onIceCandidateRemoved?: (iceCandidate: ObservedIceCandidate, ctx: Context) => void; + public onIceCandidateUpdated?: (iceCandidate: ObservedIceCandidate, ctx: Context) => void; + + public onAddedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: Context) => void; + public onRemovedIceCandidatePair?: (candidatePair: ObservedIceCandidatePair, ctx: Context) => void; + public onIceCandidatePairUpdated?: (candidatePair: ObservedIceCandidatePair, ctx: Context) => void; + + public onAddedMediaCodec?: (codec: ObservedCodec, ctx: Context) => void; + public onRemovedMediaCodec?: (codec: ObservedCodec, ctx: Context) => void; + public onMediaCodecUpdated?: (codec: ObservedCodec, ctx: Context) => void; + + public onAddedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: Context) => void; + public onRemovedMediaPlayout?: (mediaPlayout: ObservedMediaPlayout, ctx: Context) => void; + public onMediaPlayoutUpdated?: (mediaPlayout: ObservedMediaPlayout, ctx: Context) => void; + + public onMediaSourceAdded?: (mediaSource: ObservedMediaSource, ctx: Context) => void; + public onMediaSourceRemoved?: (mediaSource: ObservedMediaSource, ctx: Context) => void; + public onMediaSourceUpdated?: (mediaSource: ObservedMediaSource, ctx: Context) => void; + + private _onCallAdded(call: ObservedCall) { + const onCallEmpty = () => this.onCallEmpty?.(call, this.context); + const onCallNotEmpty = () => this.onCallNotEmpty?.(call, this.context); + const onCallUpdated = () => this.onCallUpdated?.(call, this.context); + + call.once('close', () => { + call.off('newclient', this._onClientAdded); + call.off('empty', onCallEmpty); + call.off('not-empty', onCallNotEmpty); + call.off('update', onCallUpdated); + + this.onCallRemoved?.(call, this.context); + }); + call.on('newclient', this._onClientAdded); + call.on('empty', onCallEmpty); + call.on('not-empty', onCallNotEmpty); + call.on('update', onCallUpdated); + + this.onCallAdded?.(call, this.context); + } + + private _onClientAdded(observedClient: ObservedClient) { + const onClientIssue = (issue: ClientIssue) => this._onClientIssue(observedClient, issue); + const onClientMetadata = (metaData: ClientMetaData) => this._onClientMetadata(observedClient, metaData); + const onClientJoined = () => this._onClientJoined(observedClient); + const onClientLeft = () => this._onClientLeft(observedClient); + const onClientRejoined = () => this._onClientRejoined(observedClient); + const onClientExtensionStats = (extensionStats: ExtensionStat) => this._onClientExtensionStats(observedClient, extensionStats); + const onUsingTurn = (usingTurn: boolean) => this._onUsingTurn(observedClient, usingTurn); + const onUserMediaError = (error: string) => this._onUserMediaError(observedClient, error); + const onClientUpdated = (...args: ObservedClientEvents['update']) => this.onClientUpdated?.(observedClient, args[0], this.context); + const onClientEvent = (event: ClientEvent) => this.onClientEvent?.(observedClient, event, this.context); + + observedClient.once('close', () => { + observedClient.off('newpeerconnection', this._onPeerConnconnectionAdded); + observedClient.off('issue', onClientIssue); + observedClient.off('metaData', onClientMetadata); + observedClient.off('joined', onClientJoined); + observedClient.off('left', onClientLeft); + observedClient.off('rejoined', onClientJoined); + observedClient.off('usermediaerror', onUserMediaError); + observedClient.off('usingturn', onUsingTurn); + observedClient.off('extensionStats', onClientExtensionStats); + observedClient.off('update', onClientUpdated); + observedClient.off('clientEvent', onClientEvent); + + this.onClientClosed?.(observedClient, this.context); + }); + + observedClient.on('newpeerconnection', this._onPeerConnconnectionAdded); + observedClient.on('issue', onClientIssue); + observedClient.on('metaData', onClientMetadata); + observedClient.on('joined', onClientJoined); + observedClient.on('left', onClientLeft); + observedClient.on('rejoined', onClientRejoined); + observedClient.on('usermediaerror', onUserMediaError); + observedClient.on('usingturn', onUsingTurn); + observedClient.on('extensionStats', onClientExtensionStats); + observedClient.on('update', onClientUpdated); + observedClient.on('clientEvent', onClientEvent); + + this.onClientAdded?.(observedClient, this.context); + } + + private _onClientRejoined(observedClient: ObservedClient) { + this.onClientRejoined?.(observedClient, this.context); + } + + private _onPeerConnconnectionAdded(peerConnection: ObservedPeerConnection) { + const onSelectedCandidatePairChanged = () => this.onSelectedCandidatePairChanged?.(peerConnection, this.context); + const onIceGatheringStateChange = () => this.onIceGatheringStateChange?.(peerConnection, this.context); + const onIceConnectionStateChange = () => this.onIceConnectionStateChange?.(peerConnection, this.context); + const onConnectionStateChange = () => this.onConnectionStateChange?.(peerConnection, this.context); + + peerConnection.once('close', () => { + peerConnection.off('added-certificate', this._onCertificateAdded); + peerConnection.off('removed-certificate', this._onCertificateRemoved); + peerConnection.off('updated-certificate', this._onCertificateUpdated); + + peerConnection.off('added-inbound-track', this._onInboundTrackAdded); + peerConnection.off('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.off('updated-inbound-track', this._onInboundTrackUpdated); + peerConnection.off('muted-inbound-track', this._onInboundTrackMuted); + peerConnection.off('unmuted-inbound-track', this._onInboundTrackUnmuted); + + peerConnection.off('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.off('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.off('updated-outbound-track', this._onOutboundTrackUpdated); + peerConnection.off('muted-outbound-track', this._onOutboundTrackMuted); + peerConnection.off('unmuted-outbound-track', this._onOutboundTrackUnmuted); + + peerConnection.off('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.off('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.off('updated-inbound-rtp', this._onInboundRtpUpdated); + + peerConnection.off('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.off('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.off('updated-outbound-rtp', this._onOutboundRtpUpdated); + + peerConnection.off('added-data-channel', this._onDataChannelAdded); + peerConnection.off('removed-data-channel', this._onDataChannelRemoved); + peerConnection.off('updated-data-channel', this._onDataChannelUpdated); + + peerConnection.off('added-ice-transport', this._onAddedIceTransport); + peerConnection.off('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.off('updated-ice-transport', this._onIceTransportUpdated); + + peerConnection.off('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.off('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.off('updated-ice-candidate', this._onIceCandidateUpdated); + + peerConnection.off('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.off('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.off('updated-ice-candidate-pair', this._onIceCandidatePairUpdated); + + peerConnection.off('added-codec', this._onAddedMediaCodec); + peerConnection.off('removed-codec', this._onRemovedMediaCodec); + peerConnection.off('updated-codec', this._onMediaCodecUpdated); + + peerConnection.off('added-media-playout', this._onAddedMediaPlayout); + peerConnection.off('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.off('updated-media-playout', this._onMediaPlayoutUpdated); + + peerConnection.off('added-media-source', this._onMediaSourceAdded); + peerConnection.off('removed-media-source', this._onMediaSourceRemoved); + peerConnection.off('updated-media-source', this._onMediaSourceUpdated); + + peerConnection.off('selectedcandidatepair', onSelectedCandidatePairChanged); + peerConnection.off('icegatheringstatechange', onIceGatheringStateChange); + peerConnection.off('iceconnectionstatechange', onIceConnectionStateChange); + peerConnection.off('connectionstatechange', onConnectionStateChange); + + this.onPeerConnectionRemoved?.(peerConnection, this.context); + }); + + peerConnection.on('added-certificate', this._onCertificateAdded); + peerConnection.on('removed-certificate', this._onCertificateRemoved); + peerConnection.on('updated-certificate', this._onCertificateUpdated); + + peerConnection.on('added-inbound-track', this._onInboundTrackAdded); + peerConnection.on('removed-inbound-track', this._onInboundTrackRemoved); + peerConnection.on('updated-inbound-track', this._onInboundTrackUpdated); + peerConnection.on('muted-inbound-track', this._onInboundTrackMuted); + peerConnection.on('unmuted-inbound-track', this._onInboundTrackUnmuted); + + peerConnection.on('added-outbound-track', this._onOutboundTrackAdded); + peerConnection.on('removed-outbound-track', this._onOutboundTrackRemoved); + peerConnection.on('updated-outbound-track', this._onOutboundTrackUpdated); + peerConnection.on('muted-outbound-track', this._onOutboundTrackMuted); + peerConnection.on('unmuted-outbound-track', this._onOutboundTrackUnmuted); + + peerConnection.on('added-inbound-rtp', this._onInboundRtpAdded); + peerConnection.on('removed-inbound-rtp', this._onInboundRtpRemoved); + peerConnection.on('updated-inbound-rtp', this._onInboundRtpUpdated); + + peerConnection.on('added-outbound-rtp', this._onOutboundRtpAdded); + peerConnection.on('removed-outbound-rtp', this._onOutboundRtpRemoved); + peerConnection.on('updated-outbound-rtp', this._onOutboundRtpUpdated); + + peerConnection.on('added-data-channel', this._onDataChannelAdded); + peerConnection.on('removed-data-channel', this._onDataChannelRemoved); + peerConnection.on('updated-data-channel', this._onDataChannelUpdated); + + peerConnection.on('added-ice-transport', this._onAddedIceTransport); + peerConnection.on('removed-ice-transport', this._onRemovedIceTransport); + peerConnection.on('updated-ice-transport', this._onIceTransportUpdated); + + peerConnection.on('added-ice-candidate', this._onIceCandidateAdded); + peerConnection.on('removed-ice-candidate', this._onIceCandidateRemoved); + peerConnection.on('updated-ice-candidate', this._onIceCandidateUpdated); + + peerConnection.on('added-ice-candidate-pair', this._onAddedIceCandidatePair); + peerConnection.on('removed-ice-candidate-pair', this._onRemovedIceCandidatePair); + peerConnection.on('updated-ice-candidate-pair', this._onIceCandidatePairUpdated); + + peerConnection.on('added-codec', this._onAddedMediaCodec); + peerConnection.on('removed-codec', this._onRemovedMediaCodec); + peerConnection.on('updated-codec', this._onMediaCodecUpdated); + + peerConnection.on('added-media-playout', this._onAddedMediaPlayout); + peerConnection.on('removed-media-playout', this._onRemovedMediaPlayout); + peerConnection.on('updated-media-playout', this._onMediaPlayoutUpdated); + + peerConnection.on('added-media-source', this._onMediaSourceAdded); + peerConnection.on('removed-media-source', this._onMediaSourceRemoved); + peerConnection.on('updated-media-source', this._onMediaSourceUpdated); + + peerConnection.on('selectedcandidatepair', onSelectedCandidatePairChanged); + peerConnection.on('icegatheringstatechange', onIceGatheringStateChange); + peerConnection.on('iceconnectionstatechange', onIceConnectionStateChange); + peerConnection.on('connectionstatechange', onConnectionStateChange); + + this.onPeerConnectionAdded?.(peerConnection, this.context); + + } + + private _onPeerConnectionRemoved(peerConnection: ObservedPeerConnection) { + this.onPeerConnectionRemoved?.(peerConnection, this.context); + + } + + private _onCertificateAdded(certificate: ObservedCertificate) { + this.onCertificateAdded?.(certificate, this.context); + } + + private _onCertificateRemoved(certificate: ObservedCertificate) { + this.onCertificateRemoved?.(certificate, this.context); + } + + private _onCertificateUpdated(certificate: ObservedCertificate) { + this.onCertificateUpdated?.(certificate, this.context); + } + + private _onInboundTrackAdded(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackAdded?.(inboundTrack, this.context); + } + + private _onInboundTrackRemoved(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackRemoved?.(inboundTrack, this.context); + } + + private _onInboundTrackUpdated(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackUpdated?.(inboundTrack, this.context); + } + + private _onOutboundTrackMuted(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackMuted?.(outboundTrack, this.context); + } + + private _onOutboundTrackUnmuted(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackUnmuted?.(outboundTrack, this.context); + } + + private _onOutboundTrackAdded(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackAdded?.(outboundTrack, this.context); + } + + private _onOutboundTrackRemoved(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackRemoved?.(outboundTrack, this.context); + } + + private _onOutboundTrackUpdated(outboundTrack: ObservedOutboundTrack) { + this.onOutboundTrackUpdated?.(outboundTrack, this.context); + } + + private _onInboundTrackMuted(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackMuted?.(inboundTrack, this.context); + } + + private _onInboundTrackUnmuted(inboundTrack: ObservedInboundTrack) { + this.onInboundTrackUnmuted?.(inboundTrack, this.context); + } + + private _onInboundRtpAdded(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpAdded?.(inboundRtp, this.context); + } + + private _onInboundRtpRemoved(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpRemoved?.(inboundRtp, this.context); + } + + private _onInboundRtpUpdated(inboundRtp: ObservedInboundRtp) { + this.onInboundRtpUpdated?.(inboundRtp, this.context); + } + + private _onOutboundRtpAdded(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpAdded?.(outboundRtp, this.context); + } + + private _onOutboundRtpRemoved(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpRemoved?.(outboundRtp, this.context); + } + + private _onOutboundRtpUpdated(outboundRtp: ObservedOutboundRtp) { + this.onOutboundRtpUpdated?.(outboundRtp, this.context); + } + + private _onDataChannelAdded(dataChannel: ObservedDataChannel) { + this.onDataChannelAdded?.(dataChannel, this.context); + } + + private _onDataChannelRemoved(dataChannel: ObservedDataChannel) { + this.onDataChannelRemoved?.(dataChannel, this.context); + } + + private _onDataChannelUpdated(dataChannel: ObservedDataChannel) { + this.onDataChannelUpdated?.(dataChannel, this.context); + } + + private _onAddedIceTransport(iceTransport: ObservedIceTransport) { + this.onAddedIceTransport?.(iceTransport, this.context); + } + + private _onRemovedIceTransport(iceTransport: ObservedIceTransport) { + this.onRemovedIceTransport?.(iceTransport, this.context); + } + + private _onIceTransportUpdated(iceTransport: ObservedIceTransport) { + this.onIceTransportUpdated?.(iceTransport, this.context); + } + + private _onIceCandidateAdded(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateAdded?.(iceCandidate, this.context); + } + + private _onIceCandidateRemoved(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateRemoved?.(iceCandidate, this.context); + } + + private _onIceCandidateUpdated(iceCandidate: ObservedIceCandidate) { + this.onIceCandidateUpdated?.(iceCandidate, this.context); + } + + private _onAddedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onAddedIceCandidatePair?.(candidatePair, this.context); + } + + private _onRemovedIceCandidatePair(candidatePair: ObservedIceCandidatePair) { + this.onRemovedIceCandidatePair?.(candidatePair, this.context); + } + + private _onIceCandidatePairUpdated(candidatePair: ObservedIceCandidatePair) { + this.onIceCandidatePairUpdated?.(candidatePair, this.context); + } + + private _onAddedMediaCodec(codec: ObservedCodec) { + this.onAddedMediaCodec?.(codec, this.context); + } + + private _onRemovedMediaCodec(codec: ObservedCodec) { + this.onRemovedMediaCodec?.(codec, this.context); + } + + private _onMediaCodecUpdated(codec: ObservedCodec) { + this.onMediaCodecUpdated?.(codec, this.context); + } + + private _onAddedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onAddedMediaPlayout?.(mediaPlayout, this.context); + } + + private _onRemovedMediaPlayout(mediaPlayout: ObservedMediaPlayout) { + this.onRemovedMediaPlayout?.(mediaPlayout, this.context); + } + + private _onMediaPlayoutUpdated(mediaPlayout: ObservedMediaPlayout) { + this.onMediaPlayoutUpdated?.(mediaPlayout, this.context); + } + + private _onMediaSourceAdded(mediaSource: ObservedMediaSource) { + this.onMediaSourceAdded?.(mediaSource, this.context); + } + + private _onMediaSourceRemoved(mediaSource: ObservedMediaSource) { + this.onMediaSourceRemoved?.(mediaSource, this.context); + } + + private _onMediaSourceUpdated(mediaSource: ObservedMediaSource) { + this.onMediaSourceUpdated?.(mediaSource, this.context); + } + + private _onClientIssue(observedClent: ObservedClient, issue: ClientIssue) { + this.onClientIssue?.(observedClent, issue, this.context); + } + + private _onClientMetadata(observedClient: ObservedClient, metadata: ClientMetaData) { + this.onClientMetadata?.(observedClient, metadata, this.context); + } + + private _onClientExtensionStats(observedClient: ObservedClient, extensionStats: ExtensionStat) { + this.onClientExtensionStats?.(observedClient, extensionStats, this.context); + } + + private _onClientJoined(observedClient: ObservedClient) { + this.onClientJoined?.(observedClient, this.context); + } + + private _onClientLeft(observedClient: ObservedClient) { + this.onClientLeft?.(observedClient, this.context); + } + + private _onUserMediaError(observedClient: ObservedClient, error: string) { + this.onClientUserMediaError?.(observedClient, error, this.context); + } + + private _onUsingTurn(observedClient: ObservedClient, usingTurn: boolean) { + this.onClientUsingTurn?.(observedClient, usingTurn, this.context); + } +} \ No newline at end of file diff --git a/src/ObserverSummary.ts b/src/ObserverSummary.ts new file mode 100644 index 0000000..334aeaa --- /dev/null +++ b/src/ObserverSummary.ts @@ -0,0 +1,15 @@ +export type ObserverSummary = { + currentActiveCalls: number; + currentNumberOfClientsUsingTURN: number; + currentNumberOfClients: number; + + totalAddedCall: number; + totalRemovedCall: number; + + totalRttLt50Measurements: number, + totalRttLt150Measurements: number, + totalRttLt300Measurements: number, + totalRttGtOrEq300Measurements: number, + + totalClientIssues: number, +} \ No newline at end of file diff --git a/src/RemoteTrackAssigner.ts b/src/RemoteTrackAssigner.ts deleted file mode 100644 index 7188589..0000000 --- a/src/RemoteTrackAssigner.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ObservedInboundAudioTrack } from './ObservedInboundAudioTrack'; -import { ObservedInboundVideoTrack } from './ObservedInboundVideoTrack'; -import { ObservedOutboundAudioTrack } from './ObservedOutboundAudioTrack'; -import { ObservedOutboundVideoTrack } from './ObservedOutboundVideoTrack'; - -export class RemoteTrackAssigner { - public readonly sfuStreamIdToOutboundVideoTracks = new Map(); - public readonly sfuStreamIdToOutboundAudioTracks = new Map(); - public readonly pendingInboundVideoTracks = new Map(); - public readonly pendingInboundAudioTracks = new Map(); - - public addOutboundAudioTrack(track: ObservedOutboundAudioTrack) { - const untilSfuStreamIdListener = () => { - if (!track.sfuStreamId) return; - - this.sfuStreamIdToOutboundAudioTracks.set(track.sfuStreamId, track); - track.off('update', untilSfuStreamIdListener); - - // we need to assign to the pending inbound tracks - }; - - track.on('update', untilSfuStreamIdListener); - untilSfuStreamIdListener(); - } - - public addOutboundVideoTrack(track: ObservedOutboundVideoTrack) { - const untilSfuStreamIdListener = () => { - if (!track.sfuStreamId) return; - - this.sfuStreamIdToOutboundVideoTracks.set(track.sfuStreamId, track); - track.off('update', untilSfuStreamIdListener); - - // we need to assign to the pending inbound tracks - }; - - track.on('update', untilSfuStreamIdListener); - untilSfuStreamIdListener(); - } - - public addInboundAudioTrack(track: ObservedInboundAudioTrack) { - const untilSfuStreamIdListener = () => { - if (!track.sfuStreamId) return; - const outboundTrack = this.sfuStreamIdToOutboundAudioTracks.get(track.sfuStreamId); - - if (!outboundTrack) { - this.pendingInboundAudioTracks.set( - track.sfuStreamId, - (this.pendingInboundAudioTracks.get(track.sfuStreamId) || []).concat(track) - ); - - return; - } - - if (outboundTrack) { - track.remoteOutboundTrack = outboundTrack; - } else { - this.pendingInboundAudioTracks.set( - track.sfuStreamId, - (this.pendingInboundAudioTracks.get(track.sfuStreamId) || []).concat(track) - ); - } - - }; - - track.on('update', untilSfuStreamIdListener); - } - -} \ No newline at end of file diff --git a/src/ReportsCollector.ts b/src/ReportsCollector.ts deleted file mode 100644 index 5518eba..0000000 --- a/src/ReportsCollector.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { - CallEventReport, - CallMetaReport, - ClientDataChannelReport, - ClientExtensionReport, - IceCandidatePairReport, - InboundAudioTrackReport, - InboundVideoTrackReport, - ObserverEventReport, - OutboundAudioTrackReport, - OutboundVideoTrackReport, - PeerConnectionTransportReport, - SfuEventReport, - SfuExtensionReport, - SfuInboundRtpPadReport, - SfuMetaReport, - SfuOutboundRtpPadReport, - SfuSctpStreamReport, - SFUTransportReport, -} from '@observertc/report-schemas-js'; -import { EventEmitter } from 'events'; -import { ObserverSinkContext } from './common/types'; - -export type SinkEventsMap = { - 'call-event': { - reports: CallEventReport[]; - }; - 'call-meta': { - reports: CallMetaReport[]; - }; - 'client-data-channel': { - reports: ClientDataChannelReport[]; - }; - 'client-extension': { - reports: ClientExtensionReport[]; - }; - 'ice-candidate-pair': { - reports: IceCandidatePairReport[]; - }; - 'peer-connection-transport': { - reports: PeerConnectionTransportReport[]; - }; - 'inbound-audio-track': { - reports: InboundAudioTrackReport[]; - }; - 'inbound-video-track': { - reports: InboundVideoTrackReport[]; - }; - 'observer-event': { - reports: ObserverEventReport[] - }; - 'outbound-audio-track': { - reports: OutboundAudioTrackReport[]; - }; - 'outbound-video-track': { - reports: OutboundVideoTrackReport[]; - }; - 'sfu-event': { - reports: SfuEventReport[]; - }; - 'sfu-extension': { - reports: SfuExtensionReport[]; - }; - 'sfu-inbound-rtp-pad': { - reports: SfuInboundRtpPadReport[]; - }; - 'sfu-outbound-rtp-pad': { - reports: SfuOutboundRtpPadReport[]; - }; - 'sfu-sctp-stream': { - reports: SfuSctpStreamReport[]; - }; - 'sfu-transport': { - reports: SFUTransportReport[]; - }; - 'sfu-meta': { - reports: SfuMetaReport[]; - }; - 'reports': ObserverSinkContext; - 'newreport': number; -}; - -export type ObserverSinkProcess = (observerSinkContext: ObserverSinkContext) => Promise; - -export class ReportsCollector { - private _collectedReports = 0; - private _emitter = new EventEmitter(); - private _callEventReports: CallEventReport[] = []; - private _callMetaReports: CallMetaReport[] = []; - private _clientDataChannelReports: ClientDataChannelReport[] = []; - private _clientExtensionReports: ClientExtensionReport[] = []; - private _iceCandidatePairReports: IceCandidatePairReport[] = []; - private _inboundAudioTrackReports: InboundAudioTrackReport[] = []; - private _inboundVideoTrackReports: InboundVideoTrackReport[] = []; - private _peerConnectionTransportReports: PeerConnectionTransportReport[] = []; - private _observerEventReports: ObserverEventReport[] = []; - private _outboundAudioTrackReports: OutboundAudioTrackReport[] = []; - private _outboundVideoTrackReports: OutboundVideoTrackReport[] = []; - private _sfuEventReports: SfuEventReport[] = []; - private _sfuExtensionReports: SfuExtensionReport[] = []; - private _sfuInboundRtpPadReports: SfuInboundRtpPadReport[] = []; - private _sfuOutboundRtpPadReports: SfuOutboundRtpPadReport[] = []; - private _sfuSctpStreamReports: SfuSctpStreamReport[] = []; - private _sfuTransportReports: SFUTransportReport[] = []; - private _sfuMetaReports: SfuMetaReport[] = []; - - public constructor() { - // empty - } - - public on(event: K, listener: (reports: SinkEventsMap[K]) => void): this { - this._emitter.addListener(event, listener); - - return this; - } - - public off(event: K, listener: (reports: SinkEventsMap[K]) => void): this { - this._emitter.removeListener(event, listener); - - return this; - } - - public once(event: K, listener: (reports: SinkEventsMap[K]) => void): this { - this._emitter.once(event, listener); - - return this; - } - - public addCallEventReport(report: CallEventReport) { - this._callEventReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addCallMetaReport(report: CallMetaReport) { - this._callMetaReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addClientDataChannelReport(report: ClientDataChannelReport) { - this._clientDataChannelReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addClientExtensionReport(report: ClientExtensionReport) { - this._clientExtensionReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addIceCandidatePairReport(report: IceCandidatePairReport) { - this._iceCandidatePairReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addInboundAudioTrackReport(report: InboundAudioTrackReport) { - this._inboundAudioTrackReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addInboundVideoTrackReport(report: InboundVideoTrackReport) { - this._inboundVideoTrackReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addPeerConnectionTransportReports(report: PeerConnectionTransportReport) { - this._peerConnectionTransportReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addObserverEventReport(report: ObserverEventReport) { - this._observerEventReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addOutboundAudioTrackReport(report: OutboundAudioTrackReport) { - this._outboundAudioTrackReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addOutboundVideoTrackReport(report: OutboundVideoTrackReport) { - this._outboundVideoTrackReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuEventReport(report: SfuEventReport) { - this._sfuEventReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuExtensionReport(report: SfuExtensionReport) { - this._sfuExtensionReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuInboundRtpPadReport(report: SfuInboundRtpPadReport) { - this._sfuInboundRtpPadReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuOutboundRtpPadReport(report: SfuOutboundRtpPadReport) { - this._sfuOutboundRtpPadReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuSctpStreamReport(report: SfuSctpStreamReport) { - this._sfuSctpStreamReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuTransportReport(report: SFUTransportReport) { - this._sfuTransportReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public addSfuMetaReport(report: SfuMetaReport) { - this._sfuMetaReports.push(report); - this._emit('newreport', ++this._collectedReports); - } - - public emit(): number { - let result = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const checkAndEmit = (eventName: keyof SinkEventsMap, reports: any[]) => { - if (0 < reports.length) { - this._emit(eventName, { reports }); - result += reports.length; - } - }; - - const context = this._createObserverSinkContext(); - - checkAndEmit('call-event', this._callEventReports); - this._callEventReports = []; - - checkAndEmit('call-meta', this._callMetaReports); - this._callMetaReports = []; - - checkAndEmit('client-data-channel', this._clientDataChannelReports); - this._clientDataChannelReports = []; - - checkAndEmit('client-extension', this._clientExtensionReports); - this._clientExtensionReports = []; - - checkAndEmit('ice-candidate-pair', this._iceCandidatePairReports); - this._iceCandidatePairReports = []; - - checkAndEmit('inbound-audio-track', this._inboundAudioTrackReports); - this._inboundAudioTrackReports = []; - - checkAndEmit('inbound-video-track', this._inboundVideoTrackReports); - this._inboundVideoTrackReports = []; - - checkAndEmit('peer-connection-transport', this._peerConnectionTransportReports); - this._peerConnectionTransportReports = []; - - checkAndEmit('observer-event', this._observerEventReports); - this._observerEventReports = []; - - checkAndEmit('outbound-audio-track', this._outboundAudioTrackReports); - this._outboundAudioTrackReports = []; - - checkAndEmit('outbound-video-track', this._outboundVideoTrackReports); - this._outboundVideoTrackReports = []; - - checkAndEmit('sfu-event', this._sfuEventReports); - this._sfuEventReports = []; - - checkAndEmit('sfu-extension', this._sfuExtensionReports); - this._sfuExtensionReports = []; - - checkAndEmit('sfu-inbound-rtp-pad', this._sfuInboundRtpPadReports); - this._sfuInboundRtpPadReports = []; - - checkAndEmit('sfu-outbound-rtp-pad', this._sfuOutboundRtpPadReports); - this._sfuOutboundRtpPadReports = []; - - checkAndEmit('sfu-sctp-stream', this._sfuSctpStreamReports); - this._sfuSctpStreamReports = []; - - checkAndEmit('sfu-transport', this._sfuTransportReports); - this._sfuTransportReports = []; - - checkAndEmit('sfu-meta', this._sfuMetaReports); - this._sfuMetaReports = []; - - this._emit('reports', context); - this._collectedReports = 0; - - return result; - } - - private _emit(event: K, reports: SinkEventsMap[K]): boolean { - return this._emitter.emit(event, reports); - } - - private _createObserverSinkContext(): ObserverSinkContext { - const callEventReports = [ ...this._callEventReports ]; - const callMetaReports = [ ...this._callMetaReports ]; - const clientDataChannelReports = [ ...this._clientDataChannelReports ]; - const clientExtensionReports = [ ...this._clientExtensionReports ]; - const iceCandidatePairReports = [ ...this._iceCandidatePairReports ]; - const inboundAudioTrackReports = [ ...this._inboundAudioTrackReports ]; - const inboundVideoTrackReports = [ ...this._inboundVideoTrackReports ]; - const peerConnectionTransportReports = [ ...this._peerConnectionTransportReports ]; - const observerEventReports = [ ...this._observerEventReports ]; - const outboundAudioTrackReports = [ ...this._outboundAudioTrackReports ]; - const outboundVideoTrackReports = [ ...this._outboundVideoTrackReports ]; - const sfuEventReports = [ ...this._sfuEventReports ]; - const sfuExtensionReports = [ ...this._sfuExtensionReports ]; - const sfuInboundRtpPadReports = [ ...this._sfuInboundRtpPadReports ]; - const sfuOutboundRtpPadReports = [ ...this._sfuOutboundRtpPadReports ]; - const sfuSctpStreamReports = [ ...this._sfuSctpStreamReports ]; - const sfuTransportReports = [ ...this._sfuTransportReports ]; - const sfuMetaReports = [ ...this._sfuMetaReports ]; - - return { - callEventReports, - callMetaReports, - clientDataChannelReports, - clientExtensionReports, - iceCandidatePairReports, - inboundAudioTrackReports, - inboundVideoTrackReports, - peerConnectionTransportReports, - observerEventReports, - outboundAudioTrackReports, - outboundVideoTrackReports, - sfuEventReports, - sfuExtensionReports, - sfuInboundRtpPadReports, - sfuOutboundRtpPadReports, - sfuSctpStreamReports, - sfuTransportReports, - sfuMetaReports, - }; - } -} diff --git a/src/common/CalculatedScore.ts b/src/common/CalculatedScore.ts deleted file mode 100644 index 94aa9e7..0000000 --- a/src/common/CalculatedScore.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { ClientIssue } from '../monitors/CallSummary'; -import { ObservedInboundVideoTrack } from '../ObservedInboundVideoTrack'; -import { ObservedInboundAudioTrack } from '../ObservedInboundAudioTrack'; -import { ObservedOutboundVideoTrack } from '../ObservedOutboundVideoTrack'; -import { ObservedOutboundAudioTrack } from '../ObservedOutboundAudioTrack'; - -export type CalculationRemark = { - severity: 'none' | 'minor' | 'major' | 'critical', - text: string, -} - -export type CalculatedScore = { - score: number, - timestamp: number, - remarks: CalculationRemark[], - debugText?: string, -} - -// every track calculates its own score and stores -// the latest CalculatedScore in a score property also emits as an event 'score' -// every peer connection collects the scores and calculates its own score based on RTT, and stores it in the score property similar to track -// every client collects the scores and calculates its own score based on the peer connection scores, and stores it in the score property similar to track -// every call collects the scores and calculates its own score based on the client scores, and stores it in the score property similar to track, but -// but calls only recalculate it after a configured amount of time passed from the last recalculation, and it does not trigger automatically - -/* -Recommended bpp Ranges for Good Quality - -| Content Type | H.264 (AVC) bpp Range | H.265 (HEVC) bpp Range | VP8 bpp Range | VP9 bpp Range | -|--------------------|-----------------------|-----------------------|---------------|---------------| -| Low Motion | 0.1 - 0.2 | 0.05 - 0.15 | 0.1 - 0.2 | 0.05 - 0.15 | -| Standard Motion | 0.15 - 0.25 | 0.1 - 0.2 | 0.15 - 0.25 | 0.1 - 0.2 | -| High Motion | 0.25 - 0.4 | 0.15 - 0.3 | 0.25 - 0.4 | 0.15 - 0.3 | - -*/ -export const BPP_RANGES = { - 'lowmotion': { - 'h264': { low: 0.1, high: 0.2 }, - 'h265': { low: 0.05, high: 0.15 }, - 'vp8': { low: 0.1, high: 0.2 }, - 'vp9': { low: 0.05, high: 0.15 }, - }, - 'standard': { - 'h264': { low: 0.15, high: 0.25 }, - 'h265': { low: 0.1, high: 0.2 }, - 'vp8': { low: 0.15, high: 0.25 }, - 'vp9': { low: 0.1, high: 0.2 }, - }, - 'highmotion': { - 'h264': { low: 0.25, high: 0.4 }, - 'h265': { low: 0.15, high: 0.3 }, - 'vp8': { low: 0.25, high: 0.4 }, - 'vp9': { low: 0.15, high: 0.3 }, - }, -}; - -export function calculateBaseVideoScore(track: ObservedInboundVideoTrack | ObservedOutboundVideoTrack, newIssues: ClientIssue[]): CalculatedScore | undefined { - if (!track.highestLayer) { - return; - } - const { - frameHeight, - frameWidth, - framesPerSecond, - bitrate, - } = track.highestLayer; - const score: CalculatedScore = { - remarks: [], - score: 0.0, - timestamp: track.statsTimestamp, - }; - - if (!frameHeight || !frameWidth || !bitrate || !framesPerSecond) { - score.score = 0.0; - score.remarks.push({ - severity: 'major', - text: 'Missing data for score calculation', - }); - - return score; - } - - const bpp = bitrate / (frameHeight * frameWidth * framesPerSecond); - // let's assume vp8 for now - const bppRange = BPP_RANGES[track.contentType][track.codec ?? 'vp8']; - - if (bpp / 2 < bppRange.low) { - score.score = 0.5; - score.remarks.push({ - severity: 'major', - text: `Bitrate per pixel is too low for ${track.contentType} content`, - }); - } else if (bppRange.low < bpp) { - score.score = 0.8; - score.remarks.push({ - severity: 'minor', - text: `Bitrate per pixel is good for ${track.contentType} content`, - }); - } else { - score.score = Math.min(1.0, ((bpp - bppRange.low) / (bppRange.high - bppRange.low))); - score.remarks.push({ - severity: 'none', - text: `Bitrate per pixel is good for ${track.contentType} content`, - }); - } - - for (const issue of newIssues) { - if (issue.severity === 'critical') { - score.score = 0.0; - } else if (issue.severity === 'major') { - score.score *= 0.5; - } else if (issue.severity === 'minor') { - score.score *= 0.8; - } - score.remarks.push({ - severity: issue.severity, - text: issue.description ?? 'Issue occurred', - }); - } - - return score; -} - -export function calculateBaseAudioScore(track: ObservedInboundAudioTrack | ObservedOutboundAudioTrack, newIssues: ClientIssue[]): CalculatedScore | undefined { - const score: CalculatedScore = { - remarks: [], - score: 0.0, - timestamp: track.statsTimestamp, - }; - - if (track.bitrate < 8000) { - score.score = 0.2; - score.remarks.push({ - severity: 'none', - text: 'Bitrate is too low for good quality audio', - }); - } else if (track.bitrate < 16000) { - score.score = 0.5; - score.remarks.push({ - severity: 'none', - text: 'Bitrate is low for audio', - }); - } else { - score.score = 1.0; - score.remarks.push({ - severity: 'none', - text: 'Bitrate is good for audio', - }); - } - - for (const issues of newIssues) { - if (issues.severity === 'critical') { - score.score = 0.0; - } else if (issues.severity === 'major') { - score.score *= 0.5; - } else if (issues.severity === 'minor') { - score.score *= 0.8; - } - score.remarks.push({ - severity: issues.severity, - text: issues.description ?? 'Issue occurred', - }); - } - - return score; -} - -export function calculateLatencyMOS( - { avgJitter, rttInMs, packetsLoss }: - { avgJitter: number, rttInMs: number, packetsLoss: number }, -): number { - const effectiveLatency = rttInMs + (avgJitter * 2) + 10; - let rFactor = effectiveLatency < 160 - ? 93.2 - (effectiveLatency / 40) - : 93.2 - (effectiveLatency / 120) - 10; - - rFactor -= (packetsLoss * 2.5); - - return 1 + ((0.035) * rFactor) + ((0.000007) * rFactor * (rFactor - 60) * (100 - rFactor)); -} - -export function getRttScore(x: number): number { - // logarithmic version: 1.0 at 150 and 0.1 at 300 - return (-1.2984 * Math.log(x)) + 7.5059; - - // exponential version: 1.0 at 150 and 0.1 at 300 - // return Math.exp(-0.01536 * x); -} \ No newline at end of file diff --git a/src/common/CallEventType.ts b/src/common/CallEventType.ts deleted file mode 100644 index 7056111..0000000 --- a/src/common/CallEventType.ts +++ /dev/null @@ -1,69 +0,0 @@ -// eslint-disable-next-line no-shadow -export enum CallEventType { - CALL_STARTED = 'CALL_STARTED', - CALL_ENDED = 'CALL_ENDED', - CLIENT_JOINED = 'CLIENT_JOINED', - CLIENT_LEFT = 'CLIENT_LEFT', - PEER_CONNECTION_OPENED = 'PEER_CONNECTION_OPENED', - PEER_CONNECTION_CLOSED = 'PEER_CONNECTION_CLOSED', - MEDIA_TRACK_ADDED = 'MEDIA_TRACK_ADDED', - MEDIA_TRACK_REMOVED = 'MEDIA_TRACK_REMOVED', - MEDIA_TRACK_RESUMED = 'MEDIA_TRACK_RESUMED', - MEDIA_TRACK_MUTED = 'MEDIA_TRACK_MUTED', - MEDIA_TRACK_UNMUTED = 'MEDIA_TRACK_UNMUTED', - ICE_GATHERING_STATE_CHANGED = 'ICE_GATHERING_STATE_CHANGED', - PEER_CONNECTION_STATE_CHANGED = 'PEER_CONNECTION_STATE_CHANGED', - ICE_CONNECTION_STATE_CHANGED = 'ICE_CONNECTION_STATE_CHANGED', - DATA_CHANNEL_OPEN = 'DATA_CHANNEL_OPEN', - DATA_CHANNEL_CLOSED = 'DATA_CHANNEL_CLOSED', - DATA_CHANNEL_ERROR = 'DATA_CHANNEL_ERROR', - NEGOTIATION_NEEDED = 'NEGOTIATION_NEEDED', - SIGNALING_STATE_CHANGE = 'SIGNALING_STATE_CHANGE', - CLIENT_ISSUE = 'CLIENT_ISSUE', - - PRODUCER_ADDED = 'PRODUCER_ADDED', - PRODUCER_PAUSED = 'PRODUCER_PAUSED', - PRODUCER_RESUMED = 'PRODUCER_RESUMED', - PRODUCER_REMOVED = 'PRODUCER_REMOVED', - CONSUMER_ADDED = 'CONSUMER_ADDED', - CONSUMER_PAUSED = 'CONSUMER_PAUSED', - CONSUMER_RESUMED = 'CONSUMER_RESUMED', - CONSUMER_REMOVED = 'CONSUMER_REMOVED', - DATA_PRODUCER_OPENED = 'DATA_PRODUCER_OPENED', - DATA_PRODUCER_CLOSED = 'DATA_PRODUCER_CLOSED', - DATA_CONSUMER_OPENED = 'DATA_CONSUMER_OPENED', - DATA_CONSUMER_CLOSED = 'DATA_CONSUMER_CLOSED', -} - -export type CallEventReportType = { - name: CallEventType.MEDIA_TRACK_ADDED, - attachment: MediaTrackAddedAttachment, -} | { - name: CallEventType.PEER_CONNECTION_STATE_CHANGED, - attachment: PeerConnectionStateChangedAttachment, -} | { - name: CallEventType.ICE_CONNECTION_STATE_CHANGED, - attachment: IceConnectionStateChangedAttachment, -} | { - name: CallEventType.ICE_GATHERING_STATE_CHANGED, - attachment: IceGatheringStateChangedAttachment, -} - -export type MediaTrackAddedAttachment = { - kind?: 'audio' | 'video', - direction?: 'inbound' | 'outbound', - sfuStreamId?: string, - sfuSinkId?: string, -} - -export type PeerConnectionStateChangedAttachment = { - iceConnectionState?: 'closed' | 'connected' | 'connecting' | 'disconnected' | 'failed' | 'new', -} - -export type IceConnectionStateChangedAttachment = { - iceConnectionState?: 'new' | 'connected' | 'disconnected' | 'failed' | 'closed' | 'checking' | 'completed', -} - -export type IceGatheringStateChangedAttachment = { - iceGatheringState?: 'new' | 'gathering' | 'complete', -} diff --git a/src/common/CallMetaReports.ts b/src/common/CallMetaReports.ts deleted file mode 100644 index 52dd062..0000000 --- a/src/common/CallMetaReports.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CallMetaReport } from '@observertc/report-schemas-js'; -import { - Browser, - Certificate, - Engine, - IceLocalCandidate, - IceRemoteCandidate, - MediaCodecStats, - MediaDevice, - MediaSourceStat, - OperationSystem, - Platform, -} from '@observertc/sample-schemas-js'; - -// eslint-disable-next-line no-shadow -export enum CallMetaType { - CERTIFICATE = 'CERTIFICATE', - CODEC = 'CODEC', - ICE_LOCAL_CANDIDATE = 'ICE_LOCAL_CANDIDATE', - ICE_REMOTE_CANDIDATE = 'ICE_REMOTE_CANDIDATE', - ICE_SERVER = 'ICE_SERVER', - MEDIA_CONSTRAINT = 'MEDIA_CONSTRAINT', - MEDIA_DEVICE = 'MEDIA_DEVICE', - MEDIA_SOURCE = 'MEDIA_SOURCE', - USER_MEDIA_ERROR = 'USER_MEDIA_ERROR', - LOCAL_SDP = 'LOCAL_SDP', - - OPERATION_SYSTEM = 'OPERATION_SYSTEM', - ENGINE = 'ENGINE', - PLATFORM = 'PLATFORM', - BROWSER = 'BROWSER', -} - -export type CallMetaReportPayloads = { - [CallMetaType.CERTIFICATE]: Certificate; - [CallMetaType.CODEC]: MediaCodecStats; - [CallMetaType.ICE_LOCAL_CANDIDATE]: IceLocalCandidate; - [CallMetaType.ICE_REMOTE_CANDIDATE]: IceRemoteCandidate; - [CallMetaType.ICE_SERVER]: string; - [CallMetaType.MEDIA_CONSTRAINT]: string; - [CallMetaType.MEDIA_DEVICE]: MediaDevice; - [CallMetaType.MEDIA_SOURCE]: MediaSourceStat; - [CallMetaType.USER_MEDIA_ERROR]: string; - [CallMetaType.OPERATION_SYSTEM]: OperationSystem; - [CallMetaType.PLATFORM]: Platform; - [CallMetaType.ENGINE]: Engine; - [CallMetaType.LOCAL_SDP]: string; - [CallMetaType.BROWSER]: Browser; -} - -export type CallMetaReportType ={ - [k in keyof CallMetaReportPayloads]: { - type: k; - payload: CallMetaReportPayloads[k] - }; -}[keyof CallMetaReportPayloads]; - -export function createCallMetaReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - reportType: CallMetaReportType, - userId?: string, - peerConnectionId?: string, - timestamp?: number -) { - const report: CallMetaReport = { - type: reportType.type, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - userId, - payload: JSON.stringify(reportType.payload), - timestamp: timestamp ?? Date.now(), - }; - - return report; -} diff --git a/src/common/Middleware.ts b/src/common/Middleware.ts index 42e095e..4239da2 100644 --- a/src/common/Middleware.ts +++ b/src/common/Middleware.ts @@ -1,56 +1,84 @@ export type Middleware = ( input: T, - next: (nextInput: T) => void, + next: (nextInput: T) => void ) => void; export interface Processor { + finalCallback?: Callback; process(value: T): void; addMiddleware(...middlewares: Middleware[]): Processor; removeMiddleware(...middlewares: Middleware[]): Processor; } -export function createProcessor( -): Processor { - const stack: Middleware[] = []; - const result: Processor = { - addMiddleware: (...middlewares: Middleware[]) => { - if (middlewares && 0 < middlewares.length) { - stack.push(...middlewares); - } - - return result; - }, +type Callback = (input: T) => void; + +class Executor { + public done = false; + private index = 0; + private prevIndex = -1; + + constructor( + private readonly stack: Middleware[], + private readonly finalCallback?: Callback + ) { + } + + // Executes the middleware stack + public execute(input: T): void { + if (this.index <= this.prevIndex) { + throw new Error('Middleware must call next() only once!'); + } else if (this.done) { + throw new Error('Middleware stack has already been executed!'); + } + + this.prevIndex = this.index; + + const middleware = this.stack[this.index]; + + this.index++; + + if (middleware) { + return middleware(input, (nextInput: T) => this.execute(nextInput)); + } + + this.done = true; + this.finalCallback?.(input); + } +} + +export class MiddlewareProcessor implements Processor { + private stack: Middleware[] = []; + public finalCallback?: Callback; + + public addMiddleware(...middlewares: Middleware[]): Processor { + if (middlewares && middlewares.length > 0) { + this.stack.push(...middlewares); + } + + return this; + } - removeMiddleware: (...middlewares: Middleware[]) => { - if (middlewares && 0 < middlewares.length) { - for (const process of middlewares) { - const index = stack.indexOf(process); + public removeMiddleware(...middlewares: Middleware[]): Processor { + if (middlewares && middlewares.length > 0) { + for (const middleware of middlewares) { + const index = this.stack.indexOf(middleware); - if (0 < index) stack.splice(index, 1); + if (index >= 0) { + this.stack.splice(index, 1); } } - - return result; - }, - process: (value: T) => { - let prevIndex = -1; - const execute = (index: number, input: T): void => { - if (index <= prevIndex) { - throw new Error('middleware must call next() only once!'); - } - prevIndex = index; - const middleware = stack[index]; - - if (!middleware) return; - - const next = (nextInput: T) => execute(index + 1, nextInput); - - return middleware(input, next); - }; - - return execute(0, value); - }, - }; - - return result; -} \ No newline at end of file + } + + return this; + } + + public process(value: T): void { + if (this.stack.length === 0) { + return this.finalCallback?.(value); + } + + const executor = new Executor(this.stack, this.finalCallback); + + executor.execute(value); + } +} diff --git a/src/common/QualityScoreUtils.ts b/src/common/QualityScoreUtils.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/TypedEventEmitter.ts b/src/common/TypedEventEmitter.ts deleted file mode 100644 index cce5599..0000000 --- a/src/common/TypedEventEmitter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import EventEmitter from 'events'; - -export type EventMap = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: [...unknown[]] -} - -export function createTypedEventEmitter() { - return new EventEmitter() as TypedEventEmitter; -} - -export interface TypedEventEmitter { - addListener (event: E, listener: (...data: Events[E]) => void): this - on (event: E, listener: (...data: Events[E]) => void): this - once (event: E, listener: (...data: Events[E]) => void): this - prependListener (event: E, listener: (...data: Events[E]) => void): this - prependOnceListener (event: E, listener: (...data: Events[E]) => void): this - - off(event: E, listener: (...data: Events[E]) => void): this - removeAllListeners (event?: E): this - removeListener (event: E, listener: (...data: Events[E]) => void): this - - emit (event: E, ...args: Events[E]): boolean - // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5 - eventNames(): (keyof Events | string | symbol)[] - rawListeners (event: E): ((...data: Events[E]) => void)[] - listeners (event: E): ((...data: Events[E]) => void)[] - listenerCount (event: E): number - - getMaxListeners (): number - setMaxListeners (maxListeners: number): this -} diff --git a/src/common/callEventReports.ts b/src/common/callEventReports.ts deleted file mode 100644 index 1a2dd44..0000000 --- a/src/common/callEventReports.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { CallEventReport } from '@observertc/report-schemas-js'; -import { CallEventType } from './CallEventType'; - -export function createCallStartedEventReport( - serviceId: string, - roomId: string, - callId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.CALL_STARTED, - serviceId, - roomId, - callId, - timestamp, - marker, - }; -} - -export function createCallEndedEventReport( - serviceId: string, - roomId: string, - callId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.CALL_ENDED, - serviceId, - roomId, - callId, - timestamp, - marker, - }; -} - -export function createClientJoinedEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - timestamp: number, - userId?: string, - marker?: string -): CallEventReport { - return { - name: CallEventType.CLIENT_JOINED, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - timestamp, - userId, - marker, - }; -} - -export function createClientLeftEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - timestamp: number, - userId?: string, - marker?: string -): CallEventReport { - return { - name: CallEventType.CLIENT_LEFT, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - timestamp, - userId, - marker, - }; -} - -export function createPeerConnectionOpenedEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - peerConnectionId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.PEER_CONNECTION_OPENED, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - peerConnectionId, - timestamp, - marker, - }; -} - -export function createPeerConnectionClosedEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - peerConnectionId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.PEER_CONNECTION_CLOSED, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - peerConnectionId, - timestamp, - marker, - }; -} - -export function createMediaTrackAddedEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - peerConnectionId: string, - mediaTrackId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.MEDIA_TRACK_ADDED, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - peerConnectionId, - mediaTrackId, - timestamp, - marker, - }; -} - -export function createMediaTrackRemovedEventReport( - serviceId: string, - mediaUnitId: string, - roomId: string, - callId: string, - clientId: string, - peerConnectionId: string, - mediaTrackId: string, - timestamp: number, - marker?: string -): CallEventReport { - return { - name: CallEventType.MEDIA_TRACK_REMOVED, - serviceId, - mediaUnitId, - roomId, - callId, - clientId, - peerConnectionId, - mediaTrackId, - timestamp, - marker, - }; -} diff --git a/src/common/logger.ts b/src/common/logger.ts index 80f5a7e..46ab012 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -1,5 +1,3 @@ -export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'; - export interface Logger { /* eslint-disable @typescript-eslint/no-explicit-any */ trace(...args: any[]): void; @@ -13,79 +11,72 @@ export interface Logger { error(...args: any[]): void; } -let logLevel: LogLevel = 'error'; -const loggers = new Map(); -let target: Logger | null = { - // eslint-disable-next-line no-console - trace: (...args: any[]) => console.log(...args), - // eslint-disable-next-line no-console - debug: (...args: any[]) => console.log(...args), - // eslint-disable-next-line no-console - info: (...args: any[]) => console.log(...args), - // eslint-disable-next-line no-console - warn: (...args: any[]) => console.warn(...args), - // eslint-disable-next-line no-console - error: (...args: any[]) => console.error(...args), -}; - -export function setLogLevel(level: LogLevel) { - logLevel = level; - const moduleNames = Array.from(loggers.keys()); - - loggers.clear(); - - moduleNames.forEach((moduleName) => createLogger(moduleName)); +export interface ObserverLogger { + /* eslint-disable @typescript-eslint/no-explicit-any */ + trace(module: string, ...args: any[]): void; + /* eslint-disable @typescript-eslint/no-explicit-any */ + debug(module: string, ...args: any[]): void; + /* eslint-disable @typescript-eslint/no-explicit-any */ + info(module: string, ...args: any[]): void; + /* eslint-disable @typescript-eslint/no-explicit-any */ + warn(module: string, ...args: any[]): void; + /* eslint-disable @typescript-eslint/no-explicit-any */ + error(module: string, ...args: any[]): void; } -export const getLogLevel = () => { - return logLevel; -}; +let mainLogger: ObserverLogger = new class implements ObserverLogger { + trace = () => void 0; + // trace(module: string, ...args: any[]) { + // // eslint-disable-next-line no-console + // console.log(`[TRACE] ${module}`, ...args); + // } -export function forwardLogsTo(logger: Logger | null) { - target = logger; -} + debug(module: string, ...args: any[]) { + // eslint-disable-next-line no-console + console.log(`[DEBUG] ${module}`, ...args); + } + + info(module: string, ...args: any[]) { + // eslint-disable-next-line no-console + console.info(`[INFO] ${module}`, ...args); + } -const COLORS = { - black: '\x1b[30m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - white: '\x1b[37m', - default: '\x1b[39m', -}; + warn(module: string, ...args: any[]) { + // eslint-disable-next-line no-console + console.warn(`[WARN] ${module}`, ...args); + } -export function createLogger(moduleName: string): Logger { - if (loggers.has(moduleName)) { + error(module: string, ...args: any[]) { // eslint-disable-next-line no-console - console.warn(`Logger for module ${moduleName} already exists`); + console.error(`[ERROR] ${module}`, ...args); } - const trace = logLevel === 'trace' ? (message: string, ...args: any[]) => { - target?.trace(`${COLORS.magenta}[TRACE]${COLORS.default} ${moduleName} ${message}`, ...args); - } : () => void 0; - const debug = logLevel === 'trace' || logLevel === 'debug' ? (message: string, ...args: any[]) => { - target?.debug(`${COLORS.cyan}[DEBUG]${COLORS.default} ${moduleName} ${message}`, ...args); - } : () => void 0; - const info = logLevel === 'trace' || logLevel === 'debug' || logLevel === 'info' ? (message: string, ...args: any[]) => { - target?.info(`${COLORS.green}[INFO]${COLORS.default} ${moduleName} ${message}`, ...args); - } : () => void 0; - const warn = logLevel === 'trace' || logLevel === 'debug' || logLevel === 'info' || logLevel === 'warn' ? (message: string, ...args: any[]) => { - target?.warn(`${COLORS.yellow}[WARN]${COLORS.default} ${moduleName} ${message}`, ...args); - } : () => void 0; - const error = logLevel === 'trace' || logLevel === 'debug' || logLevel === 'info' || logLevel === 'warn' || logLevel === 'error' ? (message: string, ...args: any[]) => { - target?.error(`${COLORS.red}[ERROR]${COLORS.default} ${moduleName} ${message}`, ...args); - } : () => void 0; - const logger = { - trace, - debug, - info, - warn, - error, - }; +}(); + +export function createLogger(moduleName: string): Logger { + return new class implements Logger { + trace(...args: any[]) { + mainLogger.trace(moduleName, ...args); + } + + debug(...args: any[]) { + mainLogger.debug(moduleName, ...args); + } + + info(...args: any[]) { + mainLogger.info(moduleName, ...args); + } + + warn(...args: any[]) { + mainLogger.warn(moduleName, ...args); + } + + error(...args: any[]) { + mainLogger.error(moduleName, ...args); + } + + }(); +} - loggers.set(moduleName, logger); - - return logger; +export function setObserverLogger(logger: ObserverLogger) { + mainLogger = logger; } diff --git a/src/common/types.ts b/src/common/types.ts index a89519a..3bf8fca 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,46 +1,4 @@ -import { - CallEventReport, - CallMetaReport, - ClientDataChannelReport, - ClientExtensionReport, - IceCandidatePairReport, - InboundAudioTrackReport, - InboundVideoTrackReport, - ObserverEventReport, - OutboundAudioTrackReport, - OutboundVideoTrackReport, - PeerConnectionTransportReport, - SfuEventReport, - SfuExtensionReport, - SfuInboundRtpPadReport, - SfuMetaReport, - SfuOutboundRtpPadReport, - SfuSctpStreamReport, - SFUTransportReport, -} from '@observertc/report-schemas-js'; - export type MediaKind = 'audio' | 'video'; export type SupportedVideoCodecType = 'vp8' | 'vp9' | 'h264' | 'h265'; // export type EvaluatorMiddleware = Middleware; - -export interface ObserverSinkContext { - readonly callEventReports: CallEventReport[]; - readonly callMetaReports: CallMetaReport[]; - readonly clientDataChannelReports: ClientDataChannelReport[]; - readonly clientExtensionReports: ClientExtensionReport[]; - readonly iceCandidatePairReports: IceCandidatePairReport[]; - readonly inboundAudioTrackReports: InboundAudioTrackReport[]; - readonly inboundVideoTrackReports: InboundVideoTrackReport[]; - readonly peerConnectionTransportReports: PeerConnectionTransportReport[]; - readonly observerEventReports: ObserverEventReport[]; - readonly outboundAudioTrackReports: OutboundAudioTrackReport[]; - readonly outboundVideoTrackReports: OutboundVideoTrackReport[]; - readonly sfuEventReports: SfuEventReport[]; - readonly sfuExtensionReports: SfuExtensionReport[]; - readonly sfuInboundRtpPadReports: SfuInboundRtpPadReport[]; - readonly sfuOutboundRtpPadReports: SfuOutboundRtpPadReport[]; - readonly sfuSctpStreamReports: SfuSctpStreamReport[]; - readonly sfuTransportReports: SFUTransportReport[]; - readonly sfuMetaReports: SfuMetaReport[]; -} diff --git a/src/common/utils.ts b/src/common/utils.ts index e4f148c..2c6c4b7 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -30,9 +30,19 @@ export function isValidUuid(str: string): boolean { return regexp.test(str); } -export function getMedian(arr: number[]): number { +export function getAverage(arr: number[], defaultAvgIfArrLengthIs0 = 0): number { + if (arr.length === 0) return defaultAvgIfArrLengthIs0; + + return arr.reduce((a, b) => a + b, 0) / arr.length; +} + +export function getMedian(arr: number[], copyArrayForSorting = true): number { // Sort the array in ascending order - const sortedArr = arr.slice().sort((a, b) => a - b); + const sortedArr = copyArrayForSorting + ? arr.slice().sort((a, b) => a - b) + : arr.sort((a, b) => a-b) + + ; // Calculate the middle index const mid = Math.floor(sortedArr.length / 2); @@ -45,3 +55,15 @@ export function getMedian(arr: number[]): number { // If the array length is even, return the average of the two middle elements return (sortedArr[mid - 1] + sortedArr[mid]) / 2; } + +export function parseJsonAs(json?: string): T | undefined { + if (!json) { + return undefined; + } + + try { + return JSON.parse(json) as T; + } catch (error) { + return undefined; + } +} \ No newline at end of file diff --git a/src/detectors/Detector.ts b/src/detectors/Detector.ts new file mode 100644 index 0000000..78131c0 --- /dev/null +++ b/src/detectors/Detector.ts @@ -0,0 +1,6 @@ +// import { AlertState } from "../ClientMonitor"; + +export interface Detector { + readonly name: string; + update(): void; +} \ No newline at end of file diff --git a/src/detectors/Detectors.ts b/src/detectors/Detectors.ts new file mode 100644 index 0000000..b0cfde9 --- /dev/null +++ b/src/detectors/Detectors.ts @@ -0,0 +1,38 @@ +import { createLogger } from '../common/logger'; +import { Detector } from './Detector'; + +const logger = createLogger('Detectors'); + +export class Detectors { + private _detectors: Detector[]; + + public constructor(...detectors: Detector[]) { + this._detectors = detectors; + } + + public add(detector: Detector) { + this._detectors.push(detector); + } + + public remove(detector: Detector) { + this._detectors = this._detectors.filter((d) => d !== detector); + } + + get listOfNames() { + return this._detectors.map((d) => d.name); + } + + public update() { + for (const detector of this._detectors) { + try { + detector.update(); + } catch (err) { + logger.warn(`Error updating detector ${detector?.constructor?.name}`, err); + } + } + } + + public clear() { + this._detectors = []; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c978471..2cb6a7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,93 +1,28 @@ -export type { ObserverConfig, ObserverEvents } from './Observer'; +export type { ObserverEvents } from './Observer'; export { Observer } from './Observer'; - -export * as SampleSchema from '@observertc/sample-schemas-js'; -export * as ReportSchema from '@observertc/report-schemas-js'; - -export type { LogLevel, Logger } from './common/logger'; - -export type { - Samples, - ClientSample, - SfuSample, - ExtensionStat, - PeerConnectionTransport, - IceCandidatePair, - MediaSourceStat, - MediaCodecStats, - InboundAudioTrack, - InboundVideoTrack, - OutboundAudioTrack, - OutboundVideoTrack, - IceLocalCandidate, - IceRemoteCandidate, - CustomCallEvent, - SfuTransport, - SfuInboundRtpPad, - SfuOutboundRtpPad, - SfuSctpChannel, - SfuExtensionStats, - DataChannel, - MediaDevice, -} from '@observertc/sample-schemas-js'; - -export type { - CallEventReport, - CallMetaReport, - ClientDataChannelReport, - ClientExtensionReport, - IceCandidatePairReport, - InboundAudioTrackReport, - InboundVideoTrackReport, - ObserverEventReport, - OutboundAudioTrackReport, - OutboundVideoTrackReport, - PeerConnectionTransportReport, - SfuEventReport, - SfuExtensionReport, - SfuInboundRtpPadReport, - SfuMetaReport, - SfuOutboundRtpPadReport, - SfuSctpStreamReport, - SFUTransportReport, -} from '@observertc/report-schemas-js'; -export type { - ClientIssueMonitorConfig, - ClientIssueMonitorEmittedIssueEvent, - ClientIssueMonitor -} from './monitors/ClientIssueMonitor'; -export type { - CalculatedScore -} from './common/CalculatedScore'; -export type { ObservedCall, ObservedCallModel } from './ObservedCall'; -export type { ObservedClient, ObservedClientModel } from './ObservedClient'; -export type { ObservedPeerConnection, ObservedPeerConnectionModel, ObservedPeerConnectionEvents } from './ObservedPeerConnection'; -export type { ObservedInboundAudioTrack, ObservedInboundAudioTrackModel, ObservedInboundAudioTrackEvents } from './ObservedInboundAudioTrack'; -export type { ObservedOutboundAudioTrack, ObservedOutboundAudioTrackModel, ObservedOutboundAudioTrackEvents } from './ObservedOutboundAudioTrack'; -export type { ObservedInboundVideoTrack, ObservedInboundVideoTrackModel, ObservedInboundVideoTrackEvents } from './ObservedInboundVideoTrack'; -export type { ObservedOutboundVideoTrack, ObservedOutboundVideoTrackModel, ObservedOutboundVideoTrackEvents } from './ObservedOutboundVideoTrack'; -export type { CallSummary, ClientSummary, ClientIssue } from './monitors/CallSummary'; -export type { ObserverSinkContext } from './common/types'; -export type { SfuServerMonitorMetricsRecord } from './monitors/SfuServerMonitor'; -export type { TurnUsageMonitorEvents, TurnUsageMonitor, TurnStats, TurnUsage } from './monitors/TurnUsageMonitor'; -export { - CallEventType, - CallEventReportType, - MediaTrackAddedAttachment, - IceConnectionStateChangedAttachment, - IceGatheringStateChangedAttachment, - PeerConnectionStateChangedAttachment, -} from './common/CallEventType'; -export { CallMetaType, CallMetaReportType, CallMetaReportPayloads } from './common/CallMetaReports'; - -import { Observer, ObserverConfig } from './Observer'; -export function createObserver(config?: Partial) { - return Observer.create(config ?? {}); -} - -import { LogLevel, Logger, forwardLogsTo, setLogLevel } from './common/logger'; -export function setupLogs(logLevel: LogLevel, logger: Logger) { - setLogLevel(logLevel); - forwardLogsTo(logger); -} +export { ObservedCall } from './ObservedCall'; +export { ObservedInboundTrack } from './ObservedInboundTrack'; +export { ObservedOutboundTrack } from './ObservedOutboundTrack'; +export { ObservedClient } from './ObservedClient'; +export { ObservedPeerConnection } from './ObservedPeerConnection'; +export { ObservedMediaSource } from './ObservedMediaSource'; +export { ObservedMediaPlayout } from './ObservedMediaPlayout'; +export { ObservedCodec } from './ObservedCodec'; +export { ObservedCertificate } from './ObservedCertificate'; +export { ObservedDataChannel } from './ObservedDataChannel'; +export { ObservedInboundRtp } from './ObservedInboundRtp'; +export { ObservedOutboundRtp } from './ObservedOutboundRtp'; +export { ObservedRemoteInboundRtp } from './ObservedRemoteInboundRtp'; +export { ObservedRemoteOutboundRtp } from './ObservedRemoteOutboundRtp'; +export { ObservedIceCandidatePair } from './ObservedIceCandidatePair'; +export { ObservedIceCandidate } from './ObservedIceCandidate'; +export { ObservedIceTransport } from './ObservedIceTransport'; +export { ObservedPeerConnectionTransport } from './ObservedPeerConnectionTransport'; +export { ClientEventTypes } from './schema/ClientEventTypes'; +export { ClientMetaTypes } from './schema/ClientMetaTypes'; +export { ClientSample, ClientIssue, ClientEvent, ClientMetaData } from './schema/ClientSample'; +export { ScoreCalculator } from './scores/ScoreCalculator'; +export { ObservedClientEventMonitor } from './ObservedClientEventMonitor'; +export { ObserverEventMonitor } from './ObserverEventMonitor'; +export { Middleware } from './common/Middleware'; diff --git a/src/mediasoup/ObservedMediaRouter.ts b/src/mediasoup/ObservedMediaRouter.ts new file mode 100644 index 0000000..4b3a273 --- /dev/null +++ b/src/mediasoup/ObservedMediaRouter.ts @@ -0,0 +1,10 @@ +export type Item = { + callId: string; + appData?: Record; + region: string; + publishers: string[]; + subscribers: string[]; +} +export class ObservedMediaRouter { + +} \ No newline at end of file diff --git a/src/monitors/CallSummary.ts b/src/monitors/CallSummary.ts deleted file mode 100644 index 186b60e..0000000 --- a/src/monitors/CallSummary.ts +++ /dev/null @@ -1,38 +0,0 @@ -export interface ClientIssue extends Record { - severity: 'critical' | 'major' | 'minor'; - timestamp: number; - description?: string; - peerConnectionId?: string, - trackId?: string, - attachments?: Record, -} - -export interface ClientSummary extends Record { - clientId: string; - mediaUnitId: string; - userId?: string; - joined: number; - left?: number; - durationInMs: number; - totalOutboundAudioBytes: number, - totalOutboundVideoBytes: number, - totalInboundAudioBytes: number, - totalInboundVideoBytes: number, - totalDataChannelBytesSent: number, - totalDataChannelBytesReceived: number, - ewmaRttInMs: number, - usedTurn: boolean; - issues: ClientIssue[]; -} - -export interface CallSummary extends Record { - serviceId: string; - roomId: string; - callId: string; - started: number; - durationInMs: number; - maxNumberOfParticipants: number; - numberOfIssues: number; - highestSeverity?: ClientIssue['severity'], - clients: ClientSummary[]; -} diff --git a/src/monitors/CallSummaryMonitor.ts b/src/monitors/CallSummaryMonitor.ts deleted file mode 100644 index 0433206..0000000 --- a/src/monitors/CallSummaryMonitor.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { EventEmitter } from 'events'; -import { CallSummary, ClientSummary } from './CallSummary'; -import { ObservedCall } from '../ObservedCall'; -import { ObservedClient } from '../ObservedClient'; -import { ObservedPeerConnection } from '../ObservedPeerConnection'; -import { ObservedOutboundVideoTrack } from '../ObservedOutboundVideoTrack'; - -export type CallSummaryMonitorEvents = { - close: [], - summary: [CallSummary], -} - -export type CallSummaryMonitorConfig = { - detectUserMediaIssues?: boolean; - detectMediaTrackQualityLimitationIssues?: boolean; -} - -export declare interface CallSummaryMonitor { - on(event: U, listener: (...args: CallSummaryMonitorEvents[U]) => void): this; - off(event: U, listener: (...args: CallSummaryMonitorEvents[U]) => void): this; - once(event: U, listener: (...args: CallSummaryMonitorEvents[U]) => void): this; - emit(event: U, ...args: CallSummaryMonitorEvents[U]): boolean; -} - -export class CallSummaryMonitor extends EventEmitter { - private readonly _summaries = new Map(); - - private _closed = false; - public constructor( - public readonly config: CallSummaryMonitorConfig = {}, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public addCall(call: ObservedCall) { - if (this.closed) return; - - let callSummary: CallSummary | undefined = this._summaries.get(call.callId); - - if (!callSummary) { - callSummary = { - callId: call.callId, - roomId: call.roomId, - serviceId: call.serviceId, - clients: [], - durationInMs: 0, - maxNumberOfParticipants: 0, - numberOfIssues: 0, - started: call.created, - }; - this._summaries.set(call.callId, callSummary); - } - const constCallSummary = callSummary; - const onNewClient = (client: ObservedClient) => { - constCallSummary.maxNumberOfParticipants = Math.max(constCallSummary.maxNumberOfParticipants, call.clients.size); - this._addClient(constCallSummary, client); - }; - - call.once('close', () => { - call.off('newclient', onNewClient); - }); - call.on('newclient', onNewClient); - } - - private _addClient(callSummary: CallSummary, client: ObservedClient) { - if (this.closed) return; - const clientSummary: ClientSummary = { - clientId: client.clientId, - mediaUnitId: client.mediaUnitId, - durationInMs: 0, - totalInboundAudioBytes: 0, - totalInboundVideoBytes: 0, - totalOutboundAudioBytes: 0, - totalOutboundVideoBytes: 0, - totalDataChannelBytesSent: 0, - totalDataChannelBytesReceived: 0, - ewmaRttInMs: 0, - joined: client.created, - userId: client.userId, - usedTurn: false, - issues: [], - }; - - callSummary.clients.push(clientSummary); - - const updateClient = () => { - if (client.avgRttInMs) { - clientSummary.ewmaRttInMs = (clientSummary.ewmaRttInMs * 0.9) + (client.avgRttInMs * 0.1); - } - }; - - const onUsingTurn = (usingTurn: boolean) => { - clientSummary.usedTurn ||= usingTurn; - }; - - const onUserMediaError = (error: string) => { - if (!this.config.detectUserMediaIssues) return; - - client.addIssue({ - severity: 'critical', - timestamp: Date.now(), - description: error, - }); - }; - - const onNewPeerConnection = (peerConnection: ObservedPeerConnection) => this._addPeerConnection(clientSummary, peerConnection); - - client.on('update', updateClient); - client.on('usingturn', onUsingTurn); - client.on('usermediaerror', onUserMediaError); - client.on('newpeerconnection', onNewPeerConnection); - client.once('close', () => { - const now = Date.now(); - - clientSummary.totalInboundAudioBytes = client.totalReceivedAudioBytes; - clientSummary.totalInboundVideoBytes = client.totalReceivedVideoBytes; - clientSummary.totalOutboundAudioBytes = client.totalSentAudioBytes; - clientSummary.totalOutboundVideoBytes = client.totalSentVideoBytes; - clientSummary.totalDataChannelBytesSent = client.totalDataChannelBytesSent; - clientSummary.totalDataChannelBytesReceived = client.totalDataChannelBytesReceived; - clientSummary.durationInMs = now - client.created; - clientSummary.left = now; - // client does not store issues after update emitted - // clientSummary.issues.push(...client.issues); - - client.off('update', updateClient); - client.off('usingturn', onUsingTurn); - client.off('usermediaerror', onUserMediaError); - client.off('newpeerconnection', onNewPeerConnection); - - callSummary.durationInMs += clientSummary.durationInMs; - callSummary.numberOfIssues += clientSummary.issues.length; - callSummary.highestSeverity = clientSummary.issues.reduce((highest, issue) => { - if (issue.severity === 'critical') return 'critical'; - if (issue.severity === 'major' && highest !== 'critical') return 'major'; - if (issue.severity === 'minor' && highest !== 'critical' && highest !== 'major') return 'minor'; - - return highest; - }, callSummary.highestSeverity); - }); - } - - private _addPeerConnection(clientSummary: ClientSummary, peerConnection: ObservedPeerConnection) { - const onOutboundVideoTrack = (track: ObservedOutboundVideoTrack) => this._addOutboundVideoTrack(clientSummary, track); - - peerConnection.on('newoutboundvideotrack', onOutboundVideoTrack); - peerConnection.once('close', () => { - peerConnection.off('newoutboundvideotrack', onOutboundVideoTrack); - }); - } - - private _addOutboundVideoTrack(clientSummary: ClientSummary, track: ObservedOutboundVideoTrack) { - const onQualityLimitationChanged = (reason: string) => { - if (!this.config.detectMediaTrackQualityLimitationIssues) return; - - track.peerConnection.client.addIssue({ - severity: 'minor', - timestamp: Date.now(), - description: reason, - peerConnectionId: track.peerConnectionId, - trackId: track.trackId, - }); - }; - - track.once('close', () => { - track.off('qualitylimitationchanged', onQualityLimitationChanged); - }); - track.on('qualitylimitationchanged', onQualityLimitationChanged); - } - - public takeSummary(callId: string): CallSummary | undefined { - if (this.closed) return; - - const summary = this._summaries.get(callId); - - this._summaries.delete(callId); - - return summary; - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - this.emit('close'); - } -} \ No newline at end of file diff --git a/src/monitors/ClientIssueMonitor.ts b/src/monitors/ClientIssueMonitor.ts deleted file mode 100644 index 39ea3f1..0000000 --- a/src/monitors/ClientIssueMonitor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedCall } from '../ObservedCall'; -import { ObservedClient } from '../ObservedClient'; -import { ClientIssue } from './CallSummary'; - -export type ClientIssueMonitorEmittedIssueEvent = ClientIssue & { - clientId: string, - callId: string, -} - -export type ClientIssueMonitorEvents = { - close: [], - issue: [ClientIssueMonitorEmittedIssueEvent], - major: [ClientIssueMonitorEmittedIssueEvent], - minor: [ClientIssueMonitorEmittedIssueEvent], - critical: [ClientIssueMonitorEmittedIssueEvent], - -} - -export type ClientIssueMonitorConfig = { - // empty -} - -export declare interface ClientIssueMonitor { - on(event: U, listener: (...args: ClientIssueMonitorEvents[U]) => void): this; - off(event: U, listener: (...args: ClientIssueMonitorEvents[U]) => void): this; - once(event: U, listener: (...args: ClientIssueMonitorEvents[U]) => void): this; - emit(event: U, ...args: ClientIssueMonitorEvents[U]): boolean; -} - -export class ClientIssueMonitor extends EventEmitter { - private _closed = false; - public constructor( - public readonly config: ClientIssueMonitorConfig = {}, - ) { - super(); - this.setMaxListeners(Infinity); - } - - public addCall(call: ObservedCall) { - if (this.closed) return; - - const onNewClient = (client: ObservedClient) => { - this._addClient(call, client); - }; - - call.once('close', () => { - call.off('newclient', onNewClient); - }); - call.on('newclient', onNewClient); - } - - private _addClient(call: ObservedCall, client: ObservedClient) { - if (this.closed) return; - const { - callId - } = call; - const { - clientId - } = client; - const onIssue = (issue: ClientIssue) => { - const event: ClientIssueMonitorEmittedIssueEvent = { - ...issue, - clientId, - callId, - }; - - this.emit(issue.severity, event); - this.emit('issue', event); - }; - - client.once('close', () => { - client.off('issue', onIssue); - }); - client.on('issue', onIssue); - } - - public get closed() { - return this._closed; - } - - public close() { - if (this._closed) return; - this._closed = true; - - this.emit('close'); - } -} \ No newline at end of file diff --git a/src/monitors/SfuServerMonitor.ts b/src/monitors/SfuServerMonitor.ts deleted file mode 100644 index 168c6b5..0000000 --- a/src/monitors/SfuServerMonitor.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from '../ObservedPeerConnection'; - -export type SfuServerMonitorMetricsRecord = { - sfuId: string, - receivedPacketsPerSecond: number, - sentPacketsPerSecond: number, - receivingAudioBitrate: number, - receivingVideoBitrate: number, - sendingAudioBitrate: number, - sendingVideoBitrate: number, - numberOfPeerConnectionsRttLt50: number, - numberOfPeerConnectionsRttLt150: number, - numberOfPeerConnectionsRttLt300: number, - numberOfPeerConnectionsRttOver300: number, - numberOfPeerConnections: number, -} - -export type SfuServerMonitorConfig = { - tooHighRttAlertSettings: { - - /** - * The threshold for the RTT that will trigger the alert - */ - threshold: 'rtt-gt-300' | 'rtt-gt-150' | 'rtt-gt-50', - - /** - * The percentage of peer connections that need to be above the threshold to trigger the alert - */ - percentageOfPeerConnectionsHighWatermark: number, - - /** - * The percentage of peer connections that need to be above the threshold to clear the alert - */ - percentageOfPeerConnectionsLowWatermark: number, - - /** - * The minimum number of clients that need to be connected to trigger the alert - */ - minNumberOfPeerConnections: number, - } -} - -export type SfuServerMonitorEvents = { - 'metrics': [SfuServerMonitorMetricsRecord], - 'high-rtt-started': [{ - sfuId: string, - threshold: SfuServerMonitorConfig['tooHighRttAlertSettings']['threshold'], - percentageOfPeerConnections: number, - }], - 'high-rtt-cleared': [{ - sfuId: string, - }], - 'too-many-clients': [{ ip: string, numberOfClients: number }], - 'close': [], -} - -type PeerConnectionRecord = { - lastSendingPacketsPerSecond: number, - lastReceivingPacketsPerSecond: number, - lastSendingAudioBitrate: number, - lastSendingVideoBitrate: number, - lastReceivingAudioBitrate: number, - lastReceivingVideoBitrate: number, - lastAvgRttInMs?: number, -} - -export declare interface SfuServerMonitor { - on(event: U, listener: (...args: SfuServerMonitorEvents[U]) => void): this; - off(event: U, listener: (...args: SfuServerMonitorEvents[U]) => void): this; - once(event: U, listener: (...args: SfuServerMonitorEvents[U]) => void): this; - emit(event: U, ...args: SfuServerMonitorEvents[U]): boolean; -} - -export class SfuServerMonitor extends EventEmitter { - private _closed = false; - private readonly _peerConnections = new Map(); - private readonly _metrics = new Map(); - private readonly _rttAlertsOn = new Set(); - - public constructor( - public readonly config: SfuServerMonitorConfig - ) { - super(); - } - - public get stats() { - return Array.from(this._metrics.values()); - } - - public get closed() { - return this._closed; - } - - public close() { - if (this.closed) return; - this._closed = true; - this._peerConnections.clear(); - this._metrics.clear(); - this._rttAlertsOn.clear(); - - this.emit('close'); - } - - public addPeerConnection(peerConnection: ObservedPeerConnection) { - if (this.closed) return; - let registered = false; - - const onUpdate = () => { - const sfuId = peerConnection.client.sfuId; - const peerConnectionRecord = this._peerConnections.get(peerConnection.peerConnectionId); - - if (!sfuId) return; - - let metrics = this._metrics.get(sfuId); - - if (!metrics) { - metrics = { - sfuId, - receivedPacketsPerSecond: 0, - sentPacketsPerSecond: 0, - receivingAudioBitrate: 0, - receivingVideoBitrate: 0, - sendingAudioBitrate: 0, - sendingVideoBitrate: 0, - numberOfPeerConnectionsRttLt150: 0, - numberOfPeerConnectionsRttLt300: 0, - numberOfPeerConnectionsRttLt50: 0, - numberOfPeerConnectionsRttOver300: 0, - numberOfPeerConnections: 0, - }; - this._metrics.set(sfuId, metrics); - } - - if (!registered) { - registered = true; - ++metrics.numberOfPeerConnections; - } - - if (peerConnectionRecord) { - this._subtract(metrics, peerConnectionRecord); - } - this._increase(metrics, peerConnection); - - this._peerConnections.set(peerConnection.peerConnectionId, { - lastSendingAudioBitrate: peerConnection.sendingAudioBitrate, - lastSendingVideoBitrate: peerConnection.sendingVideoBitrate, - lastReceivingAudioBitrate: peerConnection.receivingAudioBitrate, - lastReceivingVideoBitrate: peerConnection.receivingVideoBitrate, - lastAvgRttInMs: peerConnection.avgRttInMs, - lastReceivingPacketsPerSecond: peerConnection.receivingPacketsPerSecond, - lastSendingPacketsPerSecond: peerConnection.sendingPacketsPerSecond, - }); - - this._checkHighRttForClients(metrics); - }; - - peerConnection.once('close', () => { - const address = peerConnection.ICE.selectedRemoteCandidate?.address ?? ''; - const peerConnectionRecord = this._peerConnections.get(peerConnection.peerConnectionId); - const metrics = this._metrics.get(address); - - if (metrics) { - if (peerConnectionRecord) { - this._subtract(metrics, peerConnectionRecord); - } - if (registered) { - if (--metrics.numberOfPeerConnections === 0) { - this._metrics.delete(address); - this._rttAlertsOn.delete(address); - } - } - } - this._peerConnections.delete(peerConnection.peerConnectionId); - - peerConnection.off('update', onUpdate); - }); - peerConnection.on('update', onUpdate); - } - - private _checkHighRttForClients(metrics: SfuServerMonitorMetricsRecord) { - const totalClients = metrics.numberOfPeerConnections; - const { - percentageOfPeerConnectionsHighWatermark, - percentageOfPeerConnectionsLowWatermark, - minNumberOfPeerConnections, - threshold - } = this.config.tooHighRttAlertSettings; - - if (metrics.numberOfPeerConnections < minNumberOfPeerConnections) return; - - const rttLt150Percentage = (metrics.numberOfPeerConnectionsRttLt150 + metrics.numberOfPeerConnectionsRttLt300 + metrics.numberOfPeerConnectionsRttOver300) / totalClients; - const rttLt300Percentage = (metrics.numberOfPeerConnectionsRttLt150 + metrics.numberOfPeerConnectionsRttLt300) / totalClients; - const rttOver300Percentage = metrics.numberOfPeerConnectionsRttOver300 / totalClients; - const alertOn = this._rttAlertsOn.has(metrics.sfuId); - - switch (threshold) { - case 'rtt-gt-50': { - if (!alertOn && percentageOfPeerConnectionsHighWatermark < rttLt150Percentage) { - this.emit('high-rtt-started', { - sfuId: metrics.sfuId, - threshold: 'rtt-gt-50', - percentageOfPeerConnections: rttLt150Percentage - }); - this._rttAlertsOn.add(metrics.sfuId); - } else if (alertOn && rttLt150Percentage < percentageOfPeerConnectionsLowWatermark) { - this.emit('high-rtt-cleared', { sfuId: metrics.sfuId }); - this._rttAlertsOn.delete(metrics.sfuId); - } - break; - } - case 'rtt-gt-150': { - if (!alertOn && percentageOfPeerConnectionsHighWatermark < rttLt300Percentage) { - this.emit('high-rtt-started', { - sfuId: metrics.sfuId, - threshold: 'rtt-gt-150', - percentageOfPeerConnections: rttLt300Percentage - }); - this._rttAlertsOn.add(metrics.sfuId); - } else if (alertOn && rttLt300Percentage < percentageOfPeerConnectionsLowWatermark) { - this.emit('high-rtt-cleared', { sfuId: metrics.sfuId }); - this._rttAlertsOn.delete(metrics.sfuId); - } - break; - } - case 'rtt-gt-300': { - if (!alertOn && percentageOfPeerConnectionsHighWatermark < rttOver300Percentage) { - this.emit('high-rtt-started', { - sfuId: metrics.sfuId, - threshold: 'rtt-gt-300', - percentageOfPeerConnections: rttOver300Percentage - }); - this._rttAlertsOn.add(metrics.sfuId); - } else if (alertOn && rttOver300Percentage < percentageOfPeerConnectionsLowWatermark) { - this.emit('high-rtt-cleared', { sfuId: metrics.sfuId }); - this._rttAlertsOn.delete(metrics.sfuId); - } - break; - } - } - } - - private _increase(metrics: SfuServerMonitorMetricsRecord, peerConnection: ObservedPeerConnection) { - metrics.sendingAudioBitrate += peerConnection.receivingAudioBitrate; - metrics.sendingVideoBitrate += peerConnection.receivingVideoBitrate; - metrics.receivingAudioBitrate += peerConnection.sendingAudioBitrate; - metrics.receivingVideoBitrate += peerConnection.sendingVideoBitrate; - metrics.receivedPacketsPerSecond += peerConnection.sendingPacketsPerSecond; - metrics.sentPacketsPerSecond += peerConnection.receivingPacketsPerSecond; - - if (peerConnection.avgRttInMs) { - if (peerConnection.avgRttInMs < 50) { - ++metrics.numberOfPeerConnectionsRttLt50; - } else if (peerConnection.avgRttInMs < 150) { - ++metrics.numberOfPeerConnectionsRttLt150; - } else if (peerConnection.avgRttInMs < 300) { - ++metrics.numberOfPeerConnectionsRttLt300; - } else { - ++metrics.numberOfPeerConnectionsRttOver300; - } - } - } - - private _subtract(metrics: SfuServerMonitorMetricsRecord, peerConnectionRecord: PeerConnectionRecord) { - metrics.sendingAudioBitrate -= peerConnectionRecord.lastReceivingAudioBitrate; - metrics.sendingVideoBitrate -= peerConnectionRecord.lastReceivingVideoBitrate; - metrics.receivingAudioBitrate -= peerConnectionRecord.lastSendingAudioBitrate; - metrics.receivingVideoBitrate -= peerConnectionRecord.lastSendingVideoBitrate; - metrics.receivedPacketsPerSecond -= peerConnectionRecord.lastSendingPacketsPerSecond; - metrics.sentPacketsPerSecond -= peerConnectionRecord.lastReceivingPacketsPerSecond; - - if (peerConnectionRecord.lastAvgRttInMs) { - if (peerConnectionRecord.lastAvgRttInMs < 50) { - --metrics.numberOfPeerConnectionsRttLt50; - } else if (peerConnectionRecord.lastAvgRttInMs < 150) { - --metrics.numberOfPeerConnectionsRttLt150; - } else if (peerConnectionRecord.lastAvgRttInMs < 300) { - --metrics.numberOfPeerConnectionsRttLt300; - } else { - --metrics.numberOfPeerConnectionsRttOver300; - } - } - } - -} \ No newline at end of file diff --git a/src/monitors/TurnUsageMonitor.ts b/src/monitors/TurnUsageMonitor.ts index 72cd102..8fc99f1 100644 --- a/src/monitors/TurnUsageMonitor.ts +++ b/src/monitors/TurnUsageMonitor.ts @@ -1,187 +1,187 @@ -import { EventEmitter } from 'events'; -import { ObservedPeerConnection } from '../ObservedPeerConnection'; -import { ObservedICEEvents } from '../ObservedICE'; -import { ObservedClient } from '../ObservedClient'; - -export type TurnUsageMonitorEvents = { - close: [], - 'newturnip': [string], - update: [TurnUsage], - addpeerconnection: [ObservedPeerConnection], - removepeerconnection: [ObservedPeerConnection], -} - -export type TurnUsage = { - turnIp: string; - totalBytesSent: number; - totalBytesReceived: number; - totalPacketsSent: number; - totalPacketsReceived: number; -} - -export type TurnStats = { - turnIp: string; - totalBytesSent: number; - totalBytesReceived: number; - totalPacketsSent: number; - totalPacketsReceived: number; - deltaBytesSent: number; - deltaBytesReceived: number; - timestamp: number; - outboundBitrate: number; - inboundBitrate: number; -} - -type ObservedConnection = { - peerConnection: ObservedPeerConnection, - onUpdate: (...e: ObservedICEEvents['update']) => void; - -} - -export declare interface TurnUsageMonitor { - on(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; - off(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; - once(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; - emit(event: U, ...args: TurnUsageMonitorEvents[U]): boolean; -} - -export class TurnUsageMonitor extends EventEmitter { - private readonly _connections = new Map(); - private readonly _turnUsage = new Map(); - private readonly _stats = new Map(); - - private _closed = false; - public constructor() { - super(); - this.setMaxListeners(Infinity); - } - - public get stats(): TurnStats[] { - const result: TurnStats[] = []; - - for (const [ turnIp, usage ] of this._turnUsage) { - const stats = this._stats.get(turnIp) ?? { - turnIp, - totalBytesSent: 0, - totalBytesReceived: 0, - totalPacketsSent: 0, - totalPacketsReceived: 0, - deltaBytesSent: 0, - deltaBytesReceived: 0, - timestamp: Date.now(), - outboundBitrate: 0, - inboundBitrate: 0, - }; - - stats.deltaBytesSent = usage.totalBytesSent - stats.totalBytesSent; - stats.deltaBytesReceived = usage.totalBytesReceived - stats.totalBytesReceived; - stats.timestamp = Date.now(); - stats.outboundBitrate = (stats.deltaBytesSent * 8) / ((stats.timestamp - stats.timestamp) / 1000.0); - stats.inboundBitrate = (stats.deltaBytesReceived * 8) / ((stats.timestamp - stats.timestamp) / 1000.0); - stats.totalBytesSent = usage.totalBytesSent; - stats.totalBytesReceived = usage.totalBytesReceived; - stats.totalPacketsSent = usage.totalPacketsSent; - stats.totalPacketsReceived = usage.totalPacketsReceived; - - this._stats.set(turnIp, stats); - - result.push(stats); - } - - return result; - } - - public addPeerConnection(peerConnection: ObservedPeerConnection) { - if (this.closed) return; - if (this._connections.has(peerConnection.peerConnectionId)) return; - - const ice = peerConnection.ICE; - - const onUpdate = () => { - const turnIp = ice?.selectedRemoteCandidate?.address; +// import { EventEmitter } from 'events'; +// import { ObservedPeerConnection } from '../ObservedPeerConnection'; +// import { ObservedICEEvents } from '../ObservedICE'; +// import { ObservedClient } from '../ObservedClient'; + +// export type TurnUsageMonitorEvents = { +// close: [], +// 'newturnip': [string], +// update: [TurnUsage], +// addpeerconnection: [ObservedPeerConnection], +// removepeerconnection: [ObservedPeerConnection], +// } + +// export type TurnUsage = { +// turnIp: string; +// totalBytesSent: number; +// totalBytesReceived: number; +// totalPacketsSent: number; +// totalPacketsReceived: number; +// } + +// export type TurnStats = { +// turnIp: string; +// totalBytesSent: number; +// totalBytesReceived: number; +// totalPacketsSent: number; +// totalPacketsReceived: number; +// deltaBytesSent: number; +// deltaBytesReceived: number; +// timestamp: number; +// outboundBitrate: number; +// inboundBitrate: number; +// } + +// type ObservedConnection = { +// peerConnection: ObservedPeerConnection, +// onUpdate: (...e: ObservedICEEvents['update']) => void; + +// } + +// export declare interface TurnUsageMonitor { +// on(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; +// off(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; +// once(event: U, listener: (...args: TurnUsageMonitorEvents[U]) => void): this; +// emit(event: U, ...args: TurnUsageMonitorEvents[U]): boolean; +// } + +// export class TurnUsageMonitor extends EventEmitter { +// private readonly _connections = new Map(); +// private readonly _turnUsage = new Map(); +// private readonly _stats = new Map(); + +// private _closed = false; +// public constructor() { +// super(); +// this.setMaxListeners(Infinity); +// } + +// public get stats(): TurnStats[] { +// const result: TurnStats[] = []; + +// for (const [ turnIp, usage ] of this._turnUsage) { +// const stats = this._stats.get(turnIp) ?? { +// turnIp, +// totalBytesSent: 0, +// totalBytesReceived: 0, +// totalPacketsSent: 0, +// totalPacketsReceived: 0, +// deltaBytesSent: 0, +// deltaBytesReceived: 0, +// timestamp: Date.now(), +// outboundBitrate: 0, +// inboundBitrate: 0, +// }; + +// stats.deltaBytesSent = usage.totalBytesSent - stats.totalBytesSent; +// stats.deltaBytesReceived = usage.totalBytesReceived - stats.totalBytesReceived; +// stats.timestamp = Date.now(); +// stats.outboundBitrate = (stats.deltaBytesSent * 8) / ((stats.timestamp - stats.timestamp) / 1000.0); +// stats.inboundBitrate = (stats.deltaBytesReceived * 8) / ((stats.timestamp - stats.timestamp) / 1000.0); +// stats.totalBytesSent = usage.totalBytesSent; +// stats.totalBytesReceived = usage.totalBytesReceived; +// stats.totalPacketsSent = usage.totalPacketsSent; +// stats.totalPacketsReceived = usage.totalPacketsReceived; + +// this._stats.set(turnIp, stats); + +// result.push(stats); +// } + +// return result; +// } + +// public addPeerConnection(peerConnection: ObservedPeerConnection) { +// if (this.closed) return; +// if (this._connections.has(peerConnection.peerConnectionId)) return; + +// const ice = peerConnection.ICE; + +// const onUpdate = () => { +// const turnIp = ice?.selectedRemoteCandidate?.address; - if (!peerConnection.usingTURN || !turnIp) return; +// if (!peerConnection.usingTURN || !turnIp) return; - let usage = this._turnUsage.get(turnIp); - - if (!usage) { - usage = { - turnIp, - totalBytesSent: 0, - totalBytesReceived: 0, - totalPacketsSent: 0, - totalPacketsReceived: 0, - }; - this._turnUsage.set(turnIp, usage); - - this.emit('newturnip', turnIp); - } +// let usage = this._turnUsage.get(turnIp); + +// if (!usage) { +// usage = { +// turnIp, +// totalBytesSent: 0, +// totalBytesReceived: 0, +// totalPacketsSent: 0, +// totalPacketsReceived: 0, +// }; +// this._turnUsage.set(turnIp, usage); + +// this.emit('newturnip', turnIp); +// } - usage.totalBytesSent += ice.deltaBytesSent; - usage.totalBytesReceived += ice.deltaBytesReceived; - usage.totalPacketsSent += ice.deltaPacketsSent; - usage.totalPacketsReceived += ice.deltaPacketsReceived; +// usage.totalBytesSent += ice.deltaBytesSent; +// usage.totalBytesReceived += ice.deltaBytesReceived; +// usage.totalPacketsSent += ice.deltaPacketsSent; +// usage.totalPacketsReceived += ice.deltaPacketsReceived; - this.emit('update', usage); - }; +// this.emit('update', usage); +// }; - peerConnection.ICE.on('update', onUpdate); +// peerConnection.ICE.on('update', onUpdate); - this._connections.set(peerConnection.peerConnectionId, { - peerConnection, - onUpdate - }); +// this._connections.set(peerConnection.peerConnectionId, { +// peerConnection, +// onUpdate +// }); - this.emit('addpeerconnection', peerConnection); - } +// this.emit('addpeerconnection', peerConnection); +// } - public removePeerConnection(peerConnection: ObservedPeerConnection) { - if (this.closed) return; - if (!this._connections.has(peerConnection.peerConnectionId)) return; +// public removePeerConnection(peerConnection: ObservedPeerConnection) { +// if (this.closed) return; +// if (!this._connections.has(peerConnection.peerConnectionId)) return; - const { onUpdate } = this._connections.get(peerConnection.peerConnectionId) ?? {}; +// const { onUpdate } = this._connections.get(peerConnection.peerConnectionId) ?? {}; - if (!onUpdate) return; +// if (!onUpdate) return; - peerConnection.ICE.off('update', onUpdate); +// peerConnection.ICE.off('update', onUpdate); - this._connections.delete(peerConnection.peerConnectionId); +// this._connections.delete(peerConnection.peerConnectionId); - this.emit('removepeerconnection', peerConnection); - } +// this.emit('removepeerconnection', peerConnection); +// } - public get turnIps() { - return Array.from(this._turnUsage.keys()); - } +// public get turnIps() { +// return Array.from(this._turnUsage.keys()); +// } - public get peerConnections() { - return Array.from(this._connections.values()).map(({ peerConnection }) => peerConnection); - } +// public get peerConnections() { +// return Array.from(this._connections.values()).map(({ peerConnection }) => peerConnection); +// } - public getUsage(turnIp: string) { - return this._turnUsage.get(turnIp); - } +// public getUsage(turnIp: string) { +// return this._turnUsage.get(turnIp); +// } - public get clients(): ReadonlyMap { - const result = new Map(); +// public get clients(): ReadonlyMap { +// const result = new Map(); - for (const pc of this.peerConnections) { - if (result.has(pc.clientId)) continue; +// for (const pc of this.peerConnections) { +// if (result.has(pc.clientId)) continue; - result.set(pc.clientId, pc.client); - } +// result.set(pc.clientId, pc.client); +// } - return result; - } +// return result; +// } - public get closed() { - return this._closed; - } +// public get closed() { +// return this._closed; +// } - public close() { - if (this._closed) return; - this._closed = true; +// public close() { +// if (this._closed) return; +// this._closed = true; - this.emit('close'); - } -} \ No newline at end of file +// this.emit('close'); +// } +// } \ No newline at end of file diff --git a/src/schema/ClientEventTypes.ts b/src/schema/ClientEventTypes.ts new file mode 100644 index 0000000..961ead1 --- /dev/null +++ b/src/schema/ClientEventTypes.ts @@ -0,0 +1,280 @@ +/* eslint-disable no-shadow */ + +export enum ClientEventTypes { + CLIENT_JOINED = 'CLIENT_JOINED', + CLIENT_LEFT = 'CLIENT_LEFT', + PEER_CONNECTION_OPENED = 'PEER_CONNECTION_OPENED', + PEER_CONNECTION_CLOSED = 'PEER_CONNECTION_CLOSED', + MEDIA_TRACK_ADDED = 'MEDIA_TRACK_ADDED', + MEDIA_TRACK_REMOVED = 'MEDIA_TRACK_REMOVED', + MEDIA_TRACK_RESUMED = 'MEDIA_TRACK_RESUMED', + MEDIA_TRACK_MUTED = 'MEDIA_TRACK_MUTED', + MEDIA_TRACK_UNMUTED = 'MEDIA_TRACK_UNMUTED', + ICE_GATHERING_STATE_CHANGED = 'ICE_GATHERING_STATE_CHANGED', + PEER_CONNECTION_STATE_CHANGED = 'PEER_CONNECTION_STATE_CHANGED', + ICE_CONNECTION_STATE_CHANGED = 'ICE_CONNECTION_STATE_CHANGED', + DATA_CHANNEL_OPEN = 'DATA_CHANNEL_OPEN', + DATA_CHANNEL_CLOSED = 'DATA_CHANNEL_CLOSED', + DATA_CHANNEL_ERROR = 'DATA_CHANNEL_ERROR', + NEGOTIATION_NEEDED = 'NEGOTIATION_NEEDED', + SIGNALING_STATE_CHANGE = 'SIGNALING_STATE_CHANGE', + // ICE_GATHERING_STATE_CHANGE = 'ICE_GATHERING_STATE_CHANGE', + // ICE_CONNECTION_STATE_CHANGE = 'ICE_CONNECTION_STATE_CHANGE', + ICE_CANDIDATE = 'ICE_CANDIDATE', + ICE_CANDIDATE_ERROR = 'ICE_CANDIDATE_ERROR', + + // mediasoup events + PRODUCER_ADDED = 'PRODUCER_ADDED', + PRODUCER_REMOVED = 'PRODUCER_REMOVED', + PRODUCER_PAUSED = 'PRODUCER_PAUSED', + PRODUCER_RESUMED = 'PRODUCER_RESUMED', + CONSUMER_ADDED = 'CONSUMER_ADDED', + CONSUMER_REMOVED = 'CONSUMER_REMOVED', + CONSUMER_PAUSED = 'CONSUMER_PAUSED', + CONSUMER_RESUMED = 'CONSUMER_RESUMED', + DATA_PRODUCER_CREATED = 'DATA_PRODUCER_CREATED', + DATA_PRODUCER_CLOSED = 'DATA_PRODUCER_CLOSED', + DATA_CONSUMER_CREATED = 'DATA_CONSUMER_CREATED', + DATA_CONSUMER_CLOSED = 'DATA_CONSUMER_CLOSED', +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ClientJoinedEventPayload extends Record { + // empty +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ClientLeftEventPayload extends Record { +} + +export interface PeerConnectionOpenedEventPayload extends Record { + peerConnectionId: string; + iceConnectionState?: string; + iceGatheringState?: string; + signalingState?: string; +} + +export interface PeerConnectionClosedEventPayload extends Record { + peerConnectionId: string; + iceConnectionState?: string; + iceGatheringState?: string; + signalingState?: string; +} + +export interface MediaTrackAddedEventPayload extends Record { + peerConnectionId: string; + trackId: string; + kind: 'audio' | 'video'; + label?: string; + muted: boolean; + enabled: boolean; + readyState: string; + contentHint?: string; + constraints: MediaTrackConstraints, + capabilities: MediaTrackCapabilities, + settings: MediaTrackSettings, +} + +export interface MediaTrackRemovedEventPayload extends Record { + peerConnectionId: string; + trackId: string; + kind: 'audio' | 'video'; + label?: string; + muted: boolean; + enabled: boolean; + readyState: string; + contentHint?: string; +} + +export interface MediaTrackMutedEventPayload extends Record { + peerConnectionId: string; + trackId: string; + kind: 'audio' | 'video'; + label?: string; + muted: boolean; + enabled: boolean; + readyState: string; + contentHint?: string; +} + +export interface MediaTrackUnmutedEventPayload extends Record { + peerConnectionId: string; + trackId: string; + kind: 'audio' | 'video'; + label?: string; + muted: boolean; + enabled: boolean; + readyState: string; + contentHint?: string; +} + +export interface IceGatheringStateChangedEventPayload extends Record { + peerConnectionId: string; + iceGatheringState: string; +} + +export interface PeerConnectionStateChangedEventPayload extends Record { + peerConnectionId: string; + connectionState: string; +} + +export interface IceConnectionStateChangedEventPayload extends Record { + peerConnectionId: string; + iceConnectionState: string; +} + +export interface DataChannelErrorEventPayload extends Record { + label: string; + peerConnectionId: string; + readyState: string; + dataChannelId: string | number | null, + error: string | null, +} + +export interface DataChannelOpenEventPayload extends Record { + peerConnectionId: string; + label: string; + readyState: string; + dataChannelId: string | number | null, +} + +export interface DataChannelClosedEventPayload extends Record { + peerConnectionId: string; + label: string; + readyState: string; + dataChannelId: string | number | null, +} + +export interface NegotiationNeededEventPayload extends Record { + peerConnectionId: string; +} + +export interface IceCandidateEventPayload extends Record { + peerConnectionId: string; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/address) */ + address?: string | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/candidate) */ + candidate?: string; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/component) */ + component?: RTCIceComponent | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/foundation) */ + foundation?: string | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/port) */ + port?: number | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/priority) */ + priority?: number | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/protocol) */ + protocol?: RTCIceProtocol | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/relatedAddress) */ + relatedAddress?: string | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/relatedPort) */ + relatedPort?: number | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/sdpMLineIndex) */ + sdpMLineIndex?: number | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/sdpMid) */ + sdpMid?: string | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/tcpType) */ + tcpType?: RTCIceTcpCandidateType | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/type) */ + type?: RTCIceCandidateType | null; + + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/RTCIceCandidate/usernameFragment) */ + usernameFragment?: string | null; +} + +export interface IceCandidateErrorEventPayload extends Record { + peerConnectionId: string; + errorCode?: number; + errorText?: string; + address?: string | null; + port?: number | null; + url?: string | null; +} + +export interface SignalingStateChangedEventPayload extends Record { + peerConnectionId: string; + signalingState: string; +} + +export interface ProducerAddedEventPayload extends Record { + peerConnectionId: string; + producerId: string; +} + +export interface ProducerRemovedEventPayload extends Record { + peerConnectionId: string; + producerId: string; +} + +export interface ProducerPausedEventPayload extends Record { + peerConnectionId: string; + producerId: string; +} + +export interface ProducerResumedEventPayload extends Record { + peerConnectionId: string; + producerId: string; +} + +export interface ConsumerAddedEventPayload extends Record { + peerConnectionId: string; + producerId: string; + consumerId: string; + trackId: string; +} + +export interface ConsumerRemovedEventPayload extends Record { + peerConnectionId: string; + producerId: string; + consumerId: string; + trackId: string; +} + +export interface ConsumerPausedEventPayload extends Record { + peerConnectionId: string; + producerId: string; + consumerId: string; + trackId: string; +} + +export interface ConsumerResumedEventPayload extends Record { + peerConnectionId: string; + producerId: string; + consumerId: string; + trackId: string; +} + +export interface DataProducerCreatedEventPayload extends Record { + peerConnectionId: string; + dataProducerId: string; +} + +export interface DataProducerClosedEventPayload extends Record { + peerConnectionId: string; + dataProducerId: string; +} + +export interface DataConsumerCreatedEventPayload extends Record { + peerConnectionId: string; + dataProducerId: string; + dataConsumerId: string; +} + +export interface DataConsumerClosedEventPayload extends Record { + peerConnectionId: string; + dataProducerId: string; + dataConsumerId: string; +} \ No newline at end of file diff --git a/src/schema/ClientMetaTypes.ts b/src/schema/ClientMetaTypes.ts new file mode 100644 index 0000000..8908005 --- /dev/null +++ b/src/schema/ClientMetaTypes.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-shadow */ + +export enum ClientMetaTypes { + MEDIA_CONSTRAINT = 'MEDIA_CONSTRAINT', + MEDIA_DEVICE = 'MEDIA_DEVICE', + MEDIA_DEVICES_SUPPORTED_CONSTRAINTS = 'MEDIA_DEVICES_SUPPORTED_CONSTRAINTS', + USER_MEDIA_ERROR = 'USER_MEDIA_ERROR', + LOCAL_SDP = 'LOCAL_SDP', + + OPERATION_SYSTEM = 'OPERATION_SYSTEM', + ENGINE = 'ENGINE', + PLATFORM = 'PLATFORM', + BROWSER = 'BROWSER', +} + +export type MediaDeviceInfo = { + deviceId: string; + label: string; + kind: string; + groupId: string; +} + +export type Browser = { + name: string; + version: string; +} + +export type Platform = { + name: string; + version: string; +} + +export type Engine = { + name: string; + version: string; +} + +export type OperationSystem = { + name: string; + version: string; +} \ No newline at end of file diff --git a/src/schema/ClientSample.ts b/src/schema/ClientSample.ts new file mode 100644 index 0000000..deda348 --- /dev/null +++ b/src/schema/ClientSample.ts @@ -0,0 +1,1682 @@ +export const schemaVersion = '3.0.0'; + +/** +* The WebRTC app provided custom stats payload +*/ +export type ExtensionStat = { + + /** + * The type of the extension stats the custom app provides + */ + type: string; + + /** + * The payload of the extension stats the custom app provides + */ + payload?: string; + +} + +/** +* A list of additional client events. +*/ +export type ClientMetaData = { + + /** + * The name of the event used as an identifier (e.g., MEDIA_TRACK_MUTED, USER_REJOINED, etc.). + */ + type: string; + + /** + * The value associated with the event, if applicable. + */ + payload?: string; + + /** + * The unique identifier of the peer connection for which the event was generated. + */ + peerConnectionId?: string; + + /** + * The identifier of the media track related to the event, if applicable. + */ + trackId?: string; + + /** + * The SSRC (Synchronization Source) identifier associated with the event, if applicable. + */ + ssrc?: number; + + /** + * The timestamp in epoch format when the event was generated. + */ + timestamp?: number; + +} + +/** +* A list of client issues. +*/ +export type ClientIssue = { + + /** + * The name of the issue + */ + type: string; + + /** + * The value associated with the event, if applicable. + */ + payload?: string; + + /** + * The timestamp in epoch format when the event was generated. + */ + timestamp?: number; + +} + +/** +* A list of client events. +*/ +export type ClientEvent = { + + /** + * The name of the event used as an identifier (e.g., MEDIA_TRACK_MUTED, USER_REJOINED, etc.). + */ + type: string; + + /** + * The value associated with the event, if applicable. + */ + payload?: string; + + /** + * The timestamp in epoch format when the event was generated. + */ + timestamp?: number; + +} + +/** +* Certificates +*/ +export type CertificateStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The fingerprint of the certificate. + */ + fingerprint?: string; + + /** + * The algorithm used for the fingerprint (e.g., 'SHA-256'). + */ + fingerprintAlgorithm?: string; + + /** + * The certificate encoded in base64 format. + */ + base64Certificate?: string; + + /** + * The certificate ID of the issuer (nullable). + */ + issuerCertificateId?: string; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* ICE Candidate Pair Stats +*/ +export type IceCandidatePairStats = { + + /** + * The unique identifier for this RTCStats object. + */ + id: string; + + /** + * The timestamp of when the stats were recorded, in seconds. + */ + timestamp: number; + + /** + * The transport id of the connection this candidate pair belongs to. + */ + transportId?: string; + + /** + * The ID of the local ICE candidate in this pair. + */ + localCandidateId?: string; + + /** + * The ID of the remote ICE candidate in this pair. + */ + remoteCandidateId?: string; + + state?: 'new' | 'in-progress' | 'waiting' | 'failed' | 'succeeded' | 'cancelled' | 'inprogress'; + + /** + * Whether this candidate pair has been nominated. + */ + nominated?: boolean; + + /** + * The number of packets sent using this candidate pair. + */ + packetsSent?: number; + + /** + * The number of packets received using this candidate pair. + */ + packetsReceived?: number; + + /** + * The total number of bytes sent using this candidate pair. + */ + bytesSent?: number; + + /** + * The total number of bytes received using this candidate pair. + */ + bytesReceived?: number; + + /** + * The timestamp of the last packet sent using this candidate pair. + */ + lastPacketSentTimestamp?: number; + + /** + * The timestamp of the last packet received using this candidate pair. + */ + lastPacketReceivedTimestamp?: number; + + /** + * The total round trip time (RTT) for this candidate pair in seconds. + */ + totalRoundTripTime?: number; + + /** + * The current round trip time (RTT) for this candidate pair in seconds. + */ + currentRoundTripTime?: number; + + /** + * The available outgoing bitrate (in bits per second) for this candidate pair. + */ + availableOutgoingBitrate?: number; + + /** + * The available incoming bitrate (in bits per second) for this candidate pair. + */ + availableIncomingBitrate?: number; + + /** + * The number of ICE connection requests received by this candidate pair. + */ + requestsReceived?: number; + + /** + * The number of ICE connection requests sent by this candidate pair. + */ + requestsSent?: number; + + /** + * The number of ICE connection responses received by this candidate pair. + */ + responsesReceived?: number; + + /** + * The number of ICE connection responses sent by this candidate pair. + */ + responsesSent?: number; + + /** + * The number of ICE connection consent requests sent by this candidate pair. + */ + consentRequestsSent?: number; + + /** + * The number of packets discarded while attempting to send via this candidate pair. + */ + packetsDiscardedOnSend?: number; + + /** + * The total number of bytes discarded while attempting to send via this candidate pair. + */ + bytesDiscardedOnSend?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* ICE Candidate Stats +*/ +export type IceCandidateStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The transport ID associated with the ICE candidate. + */ + transportId?: string; + + /** + * The IP address of the ICE candidate (nullable). + */ + address?: string; + + /** + * The port number of the ICE candidate. + */ + port?: number; + + /** + * The transport protocol used by the candidate (e.g., 'udp', 'tcp'). + */ + protocol?: string; + + /** + * The type of the ICE candidate (e.g., 'host', 'srflx', 'relay'). + */ + candidateType?: string; + + /** + * The priority of the ICE candidate. + */ + priority?: number; + + /** + * The URL of the ICE candidate. + */ + url?: string; + + /** + * The protocol used for the relay (e.g., 'tcp', 'udp'). + */ + relayProtocol?: string; + + /** + * A string representing the foundation for the ICE candidate. + */ + foundation?: string; + + /** + * The related address for the ICE candidate (if any). + */ + relatedAddress?: string; + + /** + * The related port for the ICE candidate (if any). + */ + relatedPort?: number; + + /** + * The username fragment for the ICE candidate. + */ + usernameFragment?: string; + + /** + * The TCP type of the ICE candidate (e.g., 'active', 'passive'). + */ + tcpType?: string; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* ICE Transport Stats +*/ +export type IceTransportStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The number of packets sent. + */ + packetsSent?: number; + + /** + * The number of packets received. + */ + packetsReceived?: number; + + /** + * The number of bytes sent. + */ + bytesSent?: number; + + /** + * The number of bytes received. + */ + bytesReceived?: number; + + /** + * The ICE role (e.g., 'controlling', 'controlled'). + */ + iceRole?: string; + + /** + * The local username fragment for ICE. + */ + iceLocalUsernameFragment?: string; + + /** + * The DTLS transport state (e.g., 'new', 'connecting', 'connected'). + */ + dtlsState?: string; + + /** + * The ICE transport state (e.g., 'new', 'checking', 'connected'). + */ + iceState?: string; + + /** + * The ID of the selected ICE candidate pair. + */ + selectedCandidatePairId?: string; + + /** + * The ID of the local certificate. + */ + localCertificateId?: string; + + /** + * The ID of the remote certificate. + */ + remoteCertificateId?: string; + + /** + * The TLS version used for encryption. + */ + tlsVersion?: string; + + /** + * The DTLS cipher suite used. + */ + dtlsCipher?: string; + + /** + * The role in the DTLS handshake (e.g., 'client', 'server'). + */ + dtlsRole?: string; + + /** + * The SRTP cipher used for encryption. + */ + srtpCipher?: string; + + /** + * The number of changes to the selected ICE candidate pair. + */ + selectedCandidatePairChanges?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Data Channels Stats +*/ +export type DataChannelStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The label of the data channel. + */ + label?: string; + + /** + * The protocol of the data channel. + */ + protocol?: string; + + /** + * The identifier for the data channel. + */ + dataChannelIdentifier?: number; + + /** + * The state of the data channel (e.g., 'open', 'closed'). + */ + state?: string; + + /** + * The number of messages sent on the data channel. + */ + messagesSent?: number; + + /** + * The number of bytes sent on the data channel. + */ + bytesSent?: number; + + /** + * The number of messages received on the data channel. + */ + messagesReceived?: number; + + /** + * The number of bytes received on the data channel. + */ + bytesReceived?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* PeerConnection Transport Stats +*/ +export type PeerConnectionTransportStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The number of data channels opened. + */ + dataChannelsOpened?: number; + + /** + * The number of data channels closed. + */ + dataChannelsClosed?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Media Playout Stats +*/ +export type MediaPlayoutStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The kind of media (audio/video). + */ + kind: string; + + /** + * The duration of synthesized audio samples. + */ + synthesizedSamplesDuration?: number; + + /** + * The number of synthesized audio samples events. + */ + synthesizedSamplesEvents?: number; + + /** + * The total duration of all audio samples. + */ + totalSamplesDuration?: number; + + /** + * The total delay experienced during audio playout. + */ + totalPlayoutDelay?: number; + + /** + * The total count of audio samples. + */ + totalSamplesCount?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Audio Source Stats +*/ +export type MediaSourceStats = { + + /** + * The timestamp of the stat. + */ + timestamp: number; + + /** + * A unique identifier for the stat. + */ + id: string; + + /** + * The type of media ('audio' or 'video'). + */ + kind: string; + + /** + * The identifier of the media track. + */ + trackIdentifier?: string; + + /** + * The current audio level. + */ + audioLevel?: number; + + /** + * The total audio energy. + */ + totalAudioEnergy?: number; + + /** + * The total duration of audio samples. + */ + totalSamplesDuration?: number; + + /** + * The echo return loss. + */ + echoReturnLoss?: number; + + /** + * The enhancement of echo return loss. + */ + echoReturnLossEnhancement?: number; + + /** + * The width of the video. + */ + width?: number; + + /** + * The height of the video. + */ + height?: number; + + /** + * The total number of frames. + */ + frames?: number; + + /** + * The frames per second of the video. + */ + framesPerSecond?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Remote Outbound RTPs +*/ +export type RemoteOutboundRtpStats = { + + /** + * The timestamp for this stats object in DOMHighResTimeStamp format. + */ + timestamp: number; + + /** + * The unique identifier for this stats object. + */ + id: string; + + /** + * The SSRC identifier of the RTP stream. + */ + ssrc: number; + + /** + * The type of media ('audio' or 'video'). + */ + kind: string; + + /** + * The ID of the transport used for this stream. + */ + transportId?: string; + + /** + * The ID of the codec used for this stream. + */ + codecId?: string; + + /** + * The total number of packets sent on this stream. + */ + packetsSent?: number; + + /** + * The total number of bytes sent on this stream. + */ + bytesSent?: number; + + /** + * The ID of the local object corresponding to this stream. + */ + localId?: string; + + /** + * The remote timestamp for this stats object in DOMHighResTimeStamp format. + */ + remoteTimestamp?: number; + + /** + * The total number of reports sent on this stream. + */ + reportsSent?: number; + + /** + * The current estimated round-trip time for this stream in seconds. + */ + roundTripTime?: number; + + /** + * The total round-trip time for this stream in seconds. + */ + totalRoundTripTime?: number; + + /** + * The total number of round-trip time measurements for this stream. + */ + roundTripTimeMeasurements?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* The duration of quality limitation reasons categorized by type. +*/ +export type QualityLimitationDurations = { + + /** + * Duration of no quality limitation in seconds. + */ + none: number; + + /** + * Duration of CPU-based quality limitation in seconds. + */ + cpu: number; + + /** + * Duration of bandwidth-based quality limitation in seconds. + */ + bandwidth: number; + + /** + * Duration of other quality limitation reasons in seconds. + */ + other: number; + +} + +/** +* Outbound RTPs +*/ +export type OutboundRtpStats = { + + /** + * The timestamp for this stats object in DOMHighResTimeStamp format. + */ + timestamp: number; + + /** + * The unique identifier for this stats object. + */ + id: string; + + /** + * The SSRC identifier of the RTP stream. + */ + ssrc: number; + + /** + * The type of media ('audio' or 'video'). + */ + kind: string; + + /** + * The ID of the transport used for this stream. + */ + transportId?: string; + + /** + * The ID of the codec used for this stream. + */ + codecId?: string; + + /** + * The total number of packets sent on this stream. + */ + packetsSent?: number; + + /** + * The total number of bytes sent on this stream. + */ + bytesSent?: number; + + /** + * The media ID associated with this RTP stream. + */ + mid?: string; + + /** + * The ID of the media source associated with this stream. + */ + mediaSourceId?: string; + + /** + * The ID of the remote object corresponding to this stream. + */ + remoteId?: string; + + /** + * The RID value of the RTP stream. + */ + rid?: string; + + /** + * The total number of header bytes sent on this stream. + */ + headerBytesSent?: number; + + /** + * The number of retransmitted packets sent on this stream. + */ + retransmittedPacketsSent?: number; + + /** + * The number of retransmitted bytes sent on this stream. + */ + retransmittedBytesSent?: number; + + /** + * The SSRC for the RTX stream, if applicable. + */ + rtxSsrc?: number; + + /** + * The target bitrate for this RTP stream in bits per second. + */ + targetBitrate?: number; + + /** + * The total target encoded bytes for this stream. + */ + totalEncodedBytesTarget?: number; + + /** + * The width of the frames sent in pixels. + */ + frameWidth?: number; + + /** + * The height of the frames sent in pixels. + */ + frameHeight?: number; + + /** + * The number of frames sent per second. + */ + framesPerSecond?: number; + + /** + * The total number of frames sent on this stream. + */ + framesSent?: number; + + /** + * The total number of huge frames sent on this stream. + */ + hugeFramesSent?: number; + + /** + * The total number of frames encoded on this stream. + */ + framesEncoded?: number; + + /** + * The total number of key frames encoded on this stream. + */ + keyFramesEncoded?: number; + + /** + * The sum of QP values for all frames encoded on this stream. + */ + qpSum?: number; + + /** + * The total time spent encoding frames on this stream in seconds. + */ + totalEncodeTime?: number; + + /** + * The total delay for packets sent on this stream in seconds. + */ + totalPacketSendDelay?: number; + + /** + * The reason for any quality limitation on this stream. + */ + qualityLimitationReason?: string; + + /** + * The number of resolution changes due to quality limitations. + */ + qualityLimitationResolutionChanges?: number; + + /** + * The total number of NACK packets sent on this stream. + */ + nackCount?: number; + + /** + * The total number of FIR packets sent on this stream. + */ + firCount?: number; + + /** + * The total number of PLI packets sent on this stream. + */ + pliCount?: number; + + /** + * The implementation of the encoder used for this stream. + */ + encoderImplementation?: string; + + /** + * Indicates whether the encoder is power efficient. + */ + powerEfficientEncoder?: boolean; + + /** + * Indicates whether this stream is actively sending data. + */ + active?: boolean; + + /** + * The scalability mode of the encoder used for this stream. + */ + scalabilityMode?: string; + + /** + * The duration of quality limitation reasons categorized by type. + */ + qualityLimitationDurations?: QualityLimitationDurations; + + /** + * Additional information attached to this stats. + */ + attachments?: Record; + +} + +/** +* Remote Inbound RTPs +*/ +export type RemoteInboundRtpStats = { + + /** + * The timestamp for this stats object in DOMHighResTimeStamp format. + */ + timestamp: number; + + /** + * The unique identifier for this stats object. + */ + id: string; + + /** + * The SSRC identifier of the RTP stream. + */ + ssrc: number; + + /** + * The type of media ('audio' or 'video'). + */ + kind: string; + + /** + * The ID of the transport used for this stream. + */ + transportId?: string; + + /** + * The ID of the codec used for this stream. + */ + codecId?: string; + + /** + * The total number of packets received on this stream. + */ + packetsReceived?: number; + + /** + * The total number of packets lost on this stream. + */ + packetsLost?: number; + + /** + * The jitter value for this stream in seconds. + */ + jitter?: number; + + /** + * The ID of the local object corresponding to this remote stream. + */ + localId?: string; + + /** + * The most recent RTT measurement for this stream in seconds. + */ + roundTripTime?: number; + + /** + * The cumulative RTT for all packets on this stream in seconds. + */ + totalRoundTripTime?: number; + + /** + * The fraction of packets lost on this stream, calculated over a time interval. + */ + fractionLost?: number; + + /** + * The total number of RTT measurements for this stream. + */ + roundTripTimeMeasurements?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Inbound RTPs +*/ +export type InboundRtpStats = { + + /** + * The time the stats were collected, in high-resolution time. + */ + timestamp: number; + + /** + * Unique identifier of the stats object. + */ + id: string; + + /** + * Synchronization source identifier of the RTP stream. + */ + ssrc: number; + + /** + * Kind of the media (e.g., 'audio' or 'video'). + */ + kind: string; + + /** + * Identifier for the media track associated with the RTP stream. + */ + trackIdentifier: string; + + /** + * ID of the transport associated with the RTP stream. + */ + transportId?: string; + + /** + * ID of the codec used for the RTP stream. + */ + codecId?: string; + + /** + * Number of packets received on the RTP stream. + */ + packetsReceived?: number; + + /** + * Number of packets lost on the RTP stream. + */ + packetsLost?: number; + + /** + * Jitter of the RTP stream in seconds. + */ + jitter?: number; + + /** + * The MediaStream ID of the RTP stream. + */ + mid?: string; + + /** + * Remote stats object ID associated with the RTP stream. + */ + remoteId?: string; + + /** + * Number of frames decoded. + */ + framesDecoded?: number; + + /** + * Number of keyframes decoded. + */ + keyFramesDecoded?: number; + + /** + * Number of frames rendered. + */ + framesRendered?: number; + + /** + * Number of frames dropped. + */ + framesDropped?: number; + + /** + * Width of the decoded video frames. + */ + frameWidth?: number; + + /** + * Height of the decoded video frames. + */ + frameHeight?: number; + + /** + * Frame rate in frames per second. + */ + framesPerSecond?: number; + + /** + * Sum of the Quantization Parameter values for decoded frames. + */ + qpSum?: number; + + /** + * Total time spent decoding in seconds. + */ + totalDecodeTime?: number; + + /** + * Sum of inter-frame delays in seconds. + */ + totalInterFrameDelay?: number; + + /** + * Sum of squared inter-frame delays in seconds. + */ + totalSquaredInterFrameDelay?: number; + + /** + * Number of times playback was paused. + */ + pauseCount?: number; + + /** + * Total duration of pauses in seconds. + */ + totalPausesDuration?: number; + + /** + * Number of times playback was frozen. + */ + freezeCount?: number; + + /** + * Total duration of freezes in seconds. + */ + totalFreezesDuration?: number; + + /** + * Timestamp of the last packet received. + */ + lastPacketReceivedTimestamp?: number; + + /** + * Total header bytes received. + */ + headerBytesReceived?: number; + + /** + * Total packets discarded. + */ + packetsDiscarded?: number; + + /** + * Total bytes received from FEC. + */ + fecBytesReceived?: number; + + /** + * Total packets received from FEC. + */ + fecPacketsReceived?: number; + + /** + * Total FEC packets discarded. + */ + fecPacketsDiscarded?: number; + + /** + * Total bytes received on the RTP stream. + */ + bytesReceived?: number; + + /** + * Number of NACKs sent. + */ + nackCount?: number; + + /** + * Number of Full Intra Requests sent. + */ + firCount?: number; + + /** + * Number of Picture Loss Indications sent. + */ + pliCount?: number; + + /** + * Total processing delay in seconds. + */ + totalProcessingDelay?: number; + + /** + * Estimated timestamp of playout. + */ + estimatedPlayoutTimestamp?: number; + + /** + * Total jitter buffer delay in seconds. + */ + jitterBufferDelay?: number; + + /** + * Target delay for the jitter buffer in seconds. + */ + jitterBufferTargetDelay?: number; + + /** + * Number of packets emitted from the jitter buffer. + */ + jitterBufferEmittedCount?: number; + + /** + * Minimum delay of the jitter buffer in seconds. + */ + jitterBufferMinimumDelay?: number; + + /** + * Total audio samples received. + */ + totalSamplesReceived?: number; + + /** + * Number of concealed audio samples. + */ + concealedSamples?: number; + + /** + * Number of silent audio samples concealed. + */ + silentConcealedSamples?: number; + + /** + * Number of audio concealment events. + */ + concealmentEvents?: number; + + /** + * Number of audio samples inserted for deceleration. + */ + insertedSamplesForDeceleration?: number; + + /** + * Number of audio samples removed for acceleration. + */ + removedSamplesForAcceleration?: number; + + /** + * Audio level in the range [0.0, 1.0]. + */ + audioLevel?: number; + + /** + * Total audio energy in the stream. + */ + totalAudioEnergy?: number; + + /** + * Total duration of all received audio samples in seconds. + */ + totalSamplesDuration?: number; + + /** + * Total number of frames received. + */ + framesReceived?: number; + + /** + * Decoder implementation used for decoding frames. + */ + decoderImplementation?: string; + + /** + * Playout identifier for the RTP stream. + */ + playoutId?: string; + + /** + * Indicates if the decoder is power-efficient. + */ + powerEfficientDecoder?: boolean; + + /** + * Number of frames assembled from multiple packets. + */ + framesAssembledFromMultiplePackets?: number; + + /** + * Total assembly time for frames in seconds. + */ + totalAssemblyTime?: number; + + /** + * Number of retransmitted packets received. + */ + retransmittedPacketsReceived?: number; + + /** + * Number of retransmitted bytes received. + */ + retransmittedBytesReceived?: number; + + /** + * SSRC of the retransmission stream. + */ + rtxSsrc?: number; + + /** + * SSRC of the FEC stream. + */ + fecSsrc?: number; + + /** + * Total corruption probability of packets. + */ + totalCorruptionProbability?: number; + + /** + * Total squared corruption probability of packets. + */ + totalSquaredCorruptionProbability?: number; + + /** + * Number of corruption measurements. + */ + corruptionMeasurements?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Codec items +*/ +export type CodecStats = { + + /** + * The timestamp when the stats were generated. + */ + timestamp: number; + + /** + * The unique identifier for the stats object. + */ + id: string; + + /** + * The MIME type of the codec. + */ + mimeType: string; + + /** + * The payload type of the codec. + */ + payloadType?: number; + + /** + * The identifier of the transport associated with the codec. + */ + transportId?: string; + + /** + * The clock rate of the codec in Hz. + */ + clockRate?: number; + + /** + * The number of audio channels for the codec, if applicable. + */ + channels?: number; + + /** + * The SDP format-specific parameters line for the codec. + */ + sdpFmtpLine?: string; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Outbound Track Stats items +*/ +export type OutboundTrackSample = { + + /** + * The timestamp when the stats were generated. + */ + timestamp: number; + + /** + * The unique identifier for the stats object. + */ + id: string; + + /** + * Kind of the media (e.g., 'audio' or 'video'). + */ + kind: string; + + /** + * Calculated score for track (details should be added to attachments) + */ + score?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* Inbound Track Stats items +*/ +export type InboundTrackSample = { + + /** + * The timestamp when the stats were generated. + */ + timestamp: number; + + /** + * The unique identifier for the stats object. + */ + id: string; + + /** + * Kind of the media (e.g., 'audio' or 'video'). + */ + kind: string; + + /** + * Calculated score for track (details should be added to attachments) + */ + score?: number; + + /** + * Additional information attached to this stats + */ + attachments?: Record; + +} + +/** +* docs +*/ +export type PeerConnectionSample = { + + /** + * Unique identifier of the stats object. + */ + peerConnectionId: string; + + /** + * Additional information attached to this sample + */ + attachments?: Record; + + /** + * Calculated score for peer connection (details should be added to attachments) + */ + score?: number; + + /** + * Inbound Track Stats items + */ + inboundTracks?: InboundTrackSample[]; + + /** + * Outbound Track Stats items + */ + outboundTracks?: OutboundTrackSample[]; + + /** + * Codec items + */ + codecs?: CodecStats[]; + + /** + * Inbound RTPs + */ + inboundRtps?: InboundRtpStats[]; + + /** + * Remote Inbound RTPs + */ + remoteInboundRtps?: RemoteInboundRtpStats[]; + + /** + * Outbound RTPs + */ + outboundRtps?: OutboundRtpStats[]; + + /** + * Remote Outbound RTPs + */ + remoteOutboundRtps?: RemoteOutboundRtpStats[]; + + /** + * Audio Source Stats + */ + mediaSources?: MediaSourceStats[]; + + /** + * Media Playout Stats + */ + mediaPlayouts?: MediaPlayoutStats[]; + + /** + * PeerConnection Transport Stats + */ + peerConnectionTransports?: PeerConnectionTransportStats[]; + + /** + * Data Channels Stats + */ + dataChannels?: DataChannelStats[]; + + /** + * ICE Transport Stats + */ + iceTransports?: IceTransportStats[]; + + /** + * ICE Candidate Stats + */ + iceCandidates?: IceCandidateStats[]; + + /** + * ICE Candidate Pair Stats + */ + iceCandidatePairs?: IceCandidatePairStats[]; + + /** + * Certificates + */ + certificates?: CertificateStats[]; + +} + +/** +* docs +*/ +export type ClientSample = { + + /** + * The timestamp the sample is created in GMT + */ + timestamp: number; + + /** + * the unique identifier of the call or session + */ + callId?: string; + + /** + * Unique id of the client providing samples. + */ + clientId?: string; + + /** + * Additional information attached to this sample (e.g.: roomId, userId, displayName, etc...) + */ + attachments?: Record; + + /** + * Calculated score for client (details should be added to attachments) + */ + score?: number; + + /** + * Samples taken PeerConnections + */ + peerConnections?: PeerConnectionSample[]; + + /** + * A list of client events. + */ + clientEvents?: ClientEvent[]; + + /** + * A list of client issues. + */ + clientIssues?: ClientIssue[]; + + /** + * A list of additional client events. + */ + clientMetaItems?: ClientMetaData[]; + + /** + * The WebRTC app provided custom stats payload + */ + extensionStats?: ExtensionStat[]; + +} diff --git a/src/scores/CalculatedScore.ts b/src/scores/CalculatedScore.ts new file mode 100644 index 0000000..2774d84 --- /dev/null +++ b/src/scores/CalculatedScore.ts @@ -0,0 +1,6 @@ + +export type CalculatedScore = { + weight: number; + value?: number; + appData?: Record; +}; diff --git a/src/scores/DefaultCallScoreCalculator.ts b/src/scores/DefaultCallScoreCalculator.ts new file mode 100644 index 0000000..7f54934 --- /dev/null +++ b/src/scores/DefaultCallScoreCalculator.ts @@ -0,0 +1,23 @@ +import { ObservedCall } from '../ObservedCall'; + +export class DefaultCallScoreCalculator { + + public constructor( + public readonly observedCall: ObservedCall, + ) { + } + + public update() { + let totalScore = 0; + let totalWeight = 0; + + for (const client of this.observedCall.observedClients.values()) { + if (client.score === undefined) continue; + + totalScore += client.score * client.calculatedScore.weight; + totalWeight += client.calculatedScore.weight; + } + + this.observedCall.calculatedScore.value = totalWeight ? totalScore / totalWeight : undefined; + } +} \ No newline at end of file diff --git a/src/scores/ScoreCalculator.ts b/src/scores/ScoreCalculator.ts new file mode 100644 index 0000000..1b1a177 --- /dev/null +++ b/src/scores/ScoreCalculator.ts @@ -0,0 +1,3 @@ +export interface ScoreCalculator { + update(): void; +} diff --git a/src/updaters/CallUpdater.ts b/src/updaters/CallUpdater.ts new file mode 100644 index 0000000..449e684 --- /dev/null +++ b/src/updaters/CallUpdater.ts @@ -0,0 +1,5 @@ +import { ObservedClient } from '../ObservedClient'; + +export interface CallUpdater { + onClientUpdate(observedClient: ObservedClient): void; +} \ No newline at end of file diff --git a/src/updaters/ObserverUpdater.ts b/src/updaters/ObserverUpdater.ts new file mode 100644 index 0000000..f38b591 --- /dev/null +++ b/src/updaters/ObserverUpdater.ts @@ -0,0 +1,5 @@ +import { ObservedCall } from '../ObservedCall'; + +export interface ObserverUpdater { + onCallUpdate(observedCall: ObservedCall): void; +} \ No newline at end of file diff --git a/src/updaters/OnAllCallObserverUpdater.ts b/src/updaters/OnAllCallObserverUpdater.ts new file mode 100644 index 0000000..2f44cea --- /dev/null +++ b/src/updaters/OnAllCallObserverUpdater.ts @@ -0,0 +1,59 @@ +import { ObservedCall } from '../ObservedCall'; +import { Observer } from '../Observer'; +import { Updater } from './Updater'; + +export class OnAllCallObserverUpdater implements Updater { + public readonly name = 'OnAllCallObserverUpdater'; + public readonly description = 'Call Observer\'s update() method when all of the ObservedCalls are updated'; + + private readonly _updatedCalls = new Set(); + public closed = false; + + public constructor( + private observer: Observer + ) { + this._onNewObservedCall = this._onNewObservedCall.bind(this); + + this.observer.once('close', () => { + this.observer.off('newcall', this._onNewObservedCall); + }); + this.observer.on('newcall', this._onNewObservedCall); + } + + close(): void { + if (this.closed) return; + this.closed = true; + + this._updatedCalls.clear(); + } + + private _onNewObservedCall(observedCall: ObservedCall) { + if (this.closed) return; + + const onUpdate = () => this._onObservedCallUpdated(observedCall); + + observedCall.once('close', () => { + observedCall.off('update', onUpdate); + this._updatedCalls.delete(observedCall.callId); + + this._updateIfEveryCallUpdated(); + }); + observedCall.on('update', onUpdate); + } + + private _onObservedCallUpdated(observedCall: ObservedCall) { + if (observedCall.closed) return; + + this._updatedCalls.add(observedCall.callId); + + this._updateIfEveryCallUpdated(); + } + + private _updateIfEveryCallUpdated() { + if (this._updatedCalls.size < this.observer.observedCalls.size) return; + else if (this.closed) return; + + this._updatedCalls.clear(); + this.observer.update(); + } +} \ No newline at end of file diff --git a/src/updaters/OnAllClientCallUpdater.ts b/src/updaters/OnAllClientCallUpdater.ts new file mode 100644 index 0000000..2afcbca --- /dev/null +++ b/src/updaters/OnAllClientCallUpdater.ts @@ -0,0 +1,61 @@ +import { ObservedCall } from '../ObservedCall'; +import { ObservedClient } from '../ObservedClient'; +import { Updater } from './Updater'; + +export class OnAllClientCallUpdater implements Updater { + readonly name = 'OnAllClientCallUpdater'; + readonly description = 'Call the update() method of the ObservedCall once all client has been updated'; + + private readonly _updatedClients = new Set(); + public closed = false; + + public constructor( + private _observedCall: ObservedCall + ) { + this._onNewObservedClient = this._onNewObservedClient.bind(this); + + this._observedCall.once('close', () => { + this._observedCall.off('newclient', this._onNewObservedClient); + }); + this._observedCall.on('newclient', this._onNewObservedClient); + } + + public close(): void { + if (this.closed) return; + this.closed = true; + // do nothing, as close once emitted unsubscription happened + this._updatedClients.clear(); + } + + private _onNewObservedClient(observedClient: ObservedClient) { + if (this.closed) return; + + const onUpdate = () => this._onClientUpdate(observedClient); + + observedClient.once('close', () => { + observedClient.off('update', onUpdate); + this._updatedClients.delete(observedClient.clientId); + this._updateIfEveryClientUpdated(); + }); + observedClient.on('update', onUpdate); + } + + private _onClientUpdate(observedClient: ObservedClient) { + if (observedClient.closed) return; + + this._updatedClients.add(observedClient.clientId); + this._updateIfEveryClientUpdated(); + } + + private _updateIfEveryClientUpdated() { + if (this._updatedClients.size < this._observedCall.observedClients.size) { + return; + } else if (this.closed) { + return; + } + + this._updatedClients.clear(); + this._observedCall.update(); + } + +} \ No newline at end of file diff --git a/src/updaters/OnAnyCallObserverUpdater.ts b/src/updaters/OnAnyCallObserverUpdater.ts new file mode 100644 index 0000000..a9c316a --- /dev/null +++ b/src/updaters/OnAnyCallObserverUpdater.ts @@ -0,0 +1,41 @@ +import { ObservedCall } from '../ObservedCall'; +import { Observer } from '../Observer'; +import { Updater } from './Updater'; + +export class OnAnyCallObserverUpdater implements Updater { + public readonly name = 'OnAnyCallObserverUpdater'; + public readonly description = 'Call Observer\'s update() method on any of the ObservedCall is updated'; + public closed = false; + + public constructor( + private observver: Observer + ) { + this._onNewObservedCall = this._onNewObservedCall.bind(this); + + this.observver.once('close', () => { + this.observver.off('newcall', this._onNewObservedCall); + }); + this.observver.on('newcall', this._onNewObservedCall); + } + + public close(): void { + if (this.closed) return; + this.closed = true; + // do nothing, because we unsubscribe once close is emitted from observer + } + + private _onNewObservedCall(observedCall: ObservedCall) { + if (this.closed) return; + + const onUpdate = () => { + if (this.closed) return; + + this.observver.update(); + }; + + observedCall.once('close', () => { + observedCall.off('update', onUpdate); + }); + observedCall.on('update', onUpdate); + } +} \ No newline at end of file diff --git a/src/updaters/OnAnyClientCallUpdater.ts b/src/updaters/OnAnyClientCallUpdater.ts new file mode 100644 index 0000000..aa894de --- /dev/null +++ b/src/updaters/OnAnyClientCallUpdater.ts @@ -0,0 +1,39 @@ +import { ObservedCall } from '../ObservedCall'; +import { ObservedClient } from '../ObservedClient'; +import { Updater } from './Updater'; + +export class OnAnyClientCallUpdater implements Updater { + readonly name = 'OnAnyClientCallUpdater'; + readonly description = 'Call the update() method of the ObservedCall when any of the client has been updated'; + public closed = false; + + public constructor( + private _observedCall: ObservedCall + ) { + this._onNewObservedClient = this._onNewObservedClient.bind(this); + + this._observedCall.once('close', () => { + this._observedCall.off('newclient', this._onNewObservedClient); + }); + this._observedCall.on('newclient', this._onNewObservedClient); + } + + public close(): void { + if (this.closed) return; + this.closed = true; + } + + private _onNewObservedClient(observedClient: ObservedClient) { + if (this.closed) return; + + const onUpdate = () => { + if (this.closed) return; + this._observedCall.update(); + }; + + observedClient.once('close', () => { + observedClient.off('update', onUpdate); + }); + observedClient.on('update', onUpdate); + } +} \ No newline at end of file diff --git a/src/updaters/OnIntervalUpdater.ts b/src/updaters/OnIntervalUpdater.ts new file mode 100644 index 0000000..2cb113d --- /dev/null +++ b/src/updaters/OnIntervalUpdater.ts @@ -0,0 +1,19 @@ +import { Updater } from './Updater'; + +export class OnIntervalUpdater implements Updater { + readonly name = 'OnIntervalUpdater'; + readonly description = 'Call the update() method given in the constructor once the interval elapsed'; + + public timer: ReturnType; + + public constructor( + intervalInMs: number, + private readonly _update: () => void, + ) { + this.timer = setInterval(() => this._update(), intervalInMs); + } + + public close(): void { + clearInterval(this.timer); + } +} \ No newline at end of file diff --git a/src/updaters/Updater.ts b/src/updaters/Updater.ts new file mode 100644 index 0000000..6bf78c3 --- /dev/null +++ b/src/updaters/Updater.ts @@ -0,0 +1,5 @@ +export interface Updater { + readonly name: string; + readonly description?: string; + close(): void; +} \ No newline at end of file diff --git a/src/utils/MediasoupRemoteTrackResolver.ts b/src/utils/MediasoupRemoteTrackResolver.ts new file mode 100644 index 0000000..1ae272b --- /dev/null +++ b/src/utils/MediasoupRemoteTrackResolver.ts @@ -0,0 +1,155 @@ +import { ObservedCall } from '../ObservedCall'; +import { ObservedCallEventMonitor } from '../ObservedCallEventMonitor'; +import { ObservedInboundTrack } from '../ObservedInboundTrack'; +import { ObservedOutboundTrack } from '../ObservedOutboundTrack'; +import { RemoteTrackResolver } from './RemoteTrackResolver'; + +export class MediasoupRemoteTrackResolver implements RemoteTrackResolver { + public readonly eventMonitor: ObservedCallEventMonitor<{ + // empty + }>; + + private _consumerIdToProducerId = new Map(); + private _producerIdToOutboundTrack = new Map(); + private _consumerIdToInboundTrack = new Map(); + // private _producerIdToOutboundTrack = new Map(); + + private _inboundTrackToConsumerId = new Map(); + private _producerIdToConsumerIds = new Map(); + private _outboundTrackToProducerId = new Map(); + + public constructor( + public readonly observedCall: ObservedCall + ) { + this.eventMonitor = this.observedCall.createEventMonitor({}); + + this.eventMonitor.onInboundTrackAdded = this._addInboundTrack.bind(this); + this.eventMonitor.onInboundTrackRemoved = this._removeInboundTrack.bind(this); + this.eventMonitor.onOutboundTrackAdded = this._addOutboundTrack.bind(this); + this.eventMonitor.onOutboundTrackRemoved = this._removeOutboundTrack.bind(this); + + // this.eventMonitor.onClientEvent = (client, event) => { + + // }; + } + + public resolveRemoteOutboundTrack(inboundTrack: ObservedInboundTrack): ObservedOutboundTrack | undefined { + const consumerId = this._inboundTrackToConsumerId.get(inboundTrack.id); + + if (!consumerId) return; + + const producerId = this._consumerIdToProducerId.get(consumerId); + + if (!producerId) return; + + return this._producerIdToOutboundTrack.get(producerId); + } + + public resolveRemoteInboundTracks(outboundTrack: ObservedOutboundTrack): ObservedInboundTrack[] | undefined { + const producerId = this._outboundTrackToProducerId.get(outboundTrack.id); + + if (!producerId) return; + + const consumerIds = this._producerIdToConsumerIds.get(producerId); + + if (!consumerIds) return; + + return consumerIds + .map((consumerId) => this._consumerIdToInboundTrack.get(consumerId)) + .filter((inboundTrack) => Boolean(inboundTrack)) as ObservedInboundTrack[] + ; + } + + private _addInboundTrack(inboundTrack: ObservedInboundTrack) { + const attachments = this._getInboundTrackAttachments(inboundTrack); + + if (!attachments) return; + + const { + producerId, + consumerId + } = attachments; + + this._inboundTrackToConsumerId.set(inboundTrack.id, consumerId); + this._consumerIdToProducerId.set(consumerId, producerId); + this._consumerIdToInboundTrack.set(consumerId, inboundTrack); + + let producerConsumers = this._producerIdToConsumerIds.get(producerId); + + if (!producerConsumers) { + producerConsumers = []; + this._producerIdToConsumerIds.set(producerId, producerConsumers); + } + producerConsumers.push(consumerId); + } + + private _removeInboundTrack(inboundTrack: ObservedInboundTrack) { + const consumerId = this._inboundTrackToConsumerId.get(inboundTrack.id); + + if (!consumerId) return; + + this._inboundTrackToConsumerId.delete(inboundTrack.id); + this._consumerIdToProducerId.delete(consumerId); + this._consumerIdToInboundTrack.delete(consumerId); + + const producerConsumers = this._producerIdToConsumerIds.get(consumerId); + + if (!producerConsumers) return; + + const filteredProducerConsumers = producerConsumers.filter((id) => id !== consumerId); + + if (filteredProducerConsumers.length === 0) { + this._producerIdToConsumerIds.delete(consumerId); + } else { + this._producerIdToConsumerIds.set(consumerId, filteredProducerConsumers); + } + } + + private _addOutboundTrack(outboundTrack: ObservedOutboundTrack) { + const attachments = this._getOutboundTrackAttachments(outboundTrack); + + if (!attachments) return; + + const { + producerId, + } = attachments; + + this._producerIdToOutboundTrack.set(producerId, outboundTrack); + this._outboundTrackToProducerId.set(outboundTrack.id, producerId); + } + + private _removeOutboundTrack(outboundTrack: ObservedOutboundTrack) { + const producerId = this._outboundTrackToProducerId.get(outboundTrack.id); + + if (!producerId) return; + + this._producerIdToOutboundTrack.delete(producerId); + this._outboundTrackToProducerId.delete(outboundTrack.id); + } + + private _getInboundTrackAttachments(inboundTrack: ObservedInboundTrack) { + const { + producerId, + consumerId + } = inboundTrack.attachments ?? {}; + + if (!producerId || !consumerId || typeof producerId !== 'string' || typeof consumerId !== 'string') return; + + return { + producerId, + consumerId + }; + } + + private _getOutboundTrackAttachments(outboundTrack: ObservedOutboundTrack) { + const { + producerId, + } = outboundTrack.attachments ?? {}; + + if (!producerId || typeof producerId !== 'string') return; + + return { + producerId, + }; + } +} \ No newline at end of file diff --git a/src/utils/RemoteTrackResolver.ts b/src/utils/RemoteTrackResolver.ts new file mode 100644 index 0000000..1383dcc --- /dev/null +++ b/src/utils/RemoteTrackResolver.ts @@ -0,0 +1,12 @@ +import { ObservedInboundTrack } from '../ObservedInboundTrack'; +import { ObservedOutboundTrack } from '../ObservedOutboundTrack'; + +export interface RemoteTrackResolver { + resolveRemoteOutboundTrack( + inboundTrack: ObservedInboundTrack, + ): ObservedOutboundTrack | undefined; + + resolveRemoteInboundTracks( + outboundTrack: ObservedOutboundTrack, + ): ObservedInboundTrack[] | undefined; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1fdd8e9..133fd36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,9 @@ "compileOnSave": true, "compilerOptions": { - "lib": [ "es2020" ], - "target": "es6", - "module": "commonjs", + "lib": [ "ES2020", "dom" ], + "target": "ESNext", + "module": "CommonJS", "moduleResolution": "node", "esModuleInterop": true, "strict": true, diff --git a/yarn.lock b/yarn.lock index 33131d5..45cc47e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -297,6 +297,13 @@ resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-1.1.1.tgz#ed2a8d25975ccc405393c96aaf707c1897e249d3" integrity sha512-1dS2m3jabfW2XL2K6//41wZkO4XJOZtpe0bKLuta0BvK5LxNRyUBSSN5835n3bIFiNJKFWhDnzZEMrV7QVGFfQ== +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -547,6 +554,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + "@jridgewell/set-array@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" @@ -562,6 +574,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": version "0.3.18" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6" @@ -591,16 +611,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@observertc/report-schemas-js@2.2.10": - version "2.2.10" - resolved "https://registry.yarnpkg.com/@observertc/report-schemas-js/-/report-schemas-js-2.2.10.tgz#6ba5a7414ae4e47a5aa41321eae43b2def058427" - integrity sha512-SdsJS2/dMpK/kSjbZBuflMMibHNh5af/Q0PC29sYkCj321wNDnNSzGlf2C/w3ohSxJZBUJToG4N/ZnHRGzbtKQ== - -"@observertc/sample-schemas-js@2.2.10": - version "2.2.10" - resolved "https://registry.yarnpkg.com/@observertc/sample-schemas-js/-/sample-schemas-js-2.2.10.tgz#b7d6419cacdb79c700accd344cf110dbe93a5c5c" - integrity sha512-pz9DDh7fd7i7c6ttLaCvUh1Uile5UP6dA3y3q+SJT0JOF7NDyGCPKGMTagOknR2s21p3U/XlLLcQIH4BoveOGg== - "@sinonjs/commons@^1.7.0": version "1.8.6" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9" @@ -620,6 +630,31 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@tsconfig/node20@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-1.0.2.tgz#ada464d3f8dc6c991f64d38712573277862bb5c7" + integrity sha512-pw0MmECiSTbBfIlT0x3iQLuJ8s3i2mwYoGxJ3vzqTNMdc4nO2VeqfCOQ/doGFa8iyPlqmBd98/5pBctWz7uN2A== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.20.0" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.0.tgz#61bc5a4cae505ce98e1e36c5445e4bee060d8891" @@ -653,11 +688,6 @@ dependencies: "@babel/types" "^7.3.0" -"@types/events@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" - integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== - "@types/graceful-fs@^4.1.2": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -724,6 +754,16 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/strip-bom@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" + integrity sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ== + +"@types/strip-json-comments@0.0.30": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1" + integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ== + "@types/uuid@^8.3.4": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" @@ -848,11 +888,23 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.11.0, acorn@^8.4.1: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== + acorn@^8.2.4, acorn@^8.8.0: version "8.8.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" @@ -906,7 +958,7 @@ ansi-styles@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -anymatch@^3.0.3: +anymatch@^3.0.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -914,6 +966,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1007,6 +1064,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1022,6 +1084,13 @@ braces@^3.0.2: dependencies: fill-range "^7.0.1" +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" @@ -1105,6 +1174,21 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +chokidar@^3.5.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + ci-info@^3.2.0: version "3.8.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" @@ -1188,6 +1272,11 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1272,6 +1361,11 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -1300,6 +1394,13 @@ du@^0.1.0: dependencies: async "~0.1.22" +dynamic-dedupe@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1" + integrity sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ== + dependencies: + xtend "^4.0.0" + electron-to-chromium@^1.4.284: version "1.4.369" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.369.tgz#a98d838cdd79be4471cd04e9b4dffe891d037874" @@ -1464,6 +1565,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -1548,6 +1654,13 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -1596,6 +1709,11 @@ fsevents@^2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + fstream-ignore@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" @@ -1628,6 +1746,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -1648,7 +1771,7 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== -glob-parent@^5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -1725,6 +1848,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -1810,6 +1940,13 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-core-module@^2.11.0: version "2.12.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4" @@ -1817,6 +1954,13 @@ is-core-module@^2.11.0: dependencies: has "^1.0.3" +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1832,7 +1976,7 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -2480,7 +2624,7 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -make-error@1.x: +make-error@1.x, make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -2553,6 +2697,11 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@~1.2.0: dependencies: minimist "^1.2.6" +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -2583,7 +2732,7 @@ node-releases@^2.0.8: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -2730,7 +2879,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -2829,6 +2978,13 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -2861,6 +3017,15 @@ resolve.exports@^1.1.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999" integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ== +resolve@^1.0.0: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^1.20.0: version "1.22.2" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" @@ -2875,7 +3040,7 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@2: +rimraf@2, rimraf@^2.6.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -2947,7 +3112,7 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -source-map-support@^0.5.6: +source-map-support@^0.5.12, source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -3001,6 +3166,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + strip-bom@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" @@ -3011,6 +3181,11 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-json-comments@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -3116,6 +3291,11 @@ tr46@^2.1.0: dependencies: punycode "^2.1.1" +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + ts-jest@^27.1.5: version "27.1.5" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.1.5.tgz#0ddf1b163fbaae3d5b7504a1e65c914a95cff297" @@ -3130,6 +3310,51 @@ ts-jest@^27.1.5: semver "7.x" yargs-parser "20.x" +ts-node-dev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-2.0.0.tgz#bdd53e17ab3b5d822ef519928dc6b4a7e0f13065" + integrity sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w== + dependencies: + chokidar "^3.5.1" + dynamic-dedupe "^0.3.0" + minimist "^1.2.6" + mkdirp "^1.0.4" + resolve "^1.0.0" + rimraf "^2.6.1" + source-map-support "^0.5.12" + tree-kill "^1.2.2" + ts-node "^10.4.0" + tsconfig "^7.0.0" + +ts-node@^10.4.0: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tsconfig@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7" + integrity sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw== + dependencies: + "@types/strip-bom" "^3.0.0" + "@types/strip-json-comments" "0.0.30" + strip-bom "^3.0.0" + strip-json-comments "^2.0.0" + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -3228,6 +3453,11 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + v8-to-istanbul@^8.1.0: version "8.1.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" @@ -3347,6 +3577,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -3380,6 +3615,11 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"