diff --git a/package.json b/package.json index a32fd8c..3f31e74 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "i18next-browser-languagedetector": "^6.1.8", "i18next-http-backend": "^1.4.4", "lodash": "^4.17.21", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#23837266fca5ee799b51a722f7b8eefb2f5ac140", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#2cd38e91eee1f5b16a9be0caba6ff19486b95f31", "matrix-widget-api": "^1.0.0", "mermaid": "^8.13.8", "normalize.css": "^8.0.1", diff --git a/src/otel/OTelGroupCallMembership.ts b/src/otel/OTelGroupCallMembership.ts index 764249f..c11b6e0 100644 --- a/src/otel/OTelGroupCallMembership.ts +++ b/src/otel/OTelGroupCallMembership.ts @@ -23,8 +23,15 @@ import { RoomMember, } from "matrix-js-sdk"; import { VoipEvent } from "matrix-js-sdk/src/webrtc/call"; +import { GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall"; +import { + ConnectionStatsReport, + ByteSentStatsReport, +} from "matrix-js-sdk/src/webrtc/stats/statsReport"; +import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils"; import { ElementCallOpenTelemetry } from "./otel"; +import { ObjectFlattener } from "./ObjectFlattener"; /** * Flattens out an object into a single layer with components @@ -73,12 +80,24 @@ function flattenVoipEventRecursive( */ export class OTelGroupCallMembership { private callMembershipSpan?: Span; - private myUserId: string; - private myMember: RoomMember; + private myUserId = "unknown"; + private myMember?: RoomMember; + private statsReportSpan: { + span: Span | undefined; + stats: OTelStatsReportEvent[]; + }; constructor(private groupCall: GroupCall, client: MatrixClient) { - this.myUserId = client.getUserId(); - this.myMember = groupCall.room.getMember(client.getUserId()); + const clientId = client.getUserId(); + if (clientId) { + this.myUserId = clientId; + const myMember = groupCall.room.getMember(clientId); + if (myMember) { + this.myMember = myMember; + } + } + + this.statsReportSpan = { span: undefined, stats: [] }; ElementCallOpenTelemetry.instance.provider.resource.attributes[ SemanticResourceAttributes.SERVICE_NAME @@ -98,7 +117,7 @@ export class OTelGroupCallMembership { this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId); this.callMembershipSpan.setAttribute( "matrix.displayName", - this.myMember.name + this.myMember ? this.myMember.name : "unknown-name" ); opentelemetry.trace.setSpan( @@ -113,7 +132,7 @@ export class OTelGroupCallMembership { this.callMembershipSpan?.addEvent("matrix.leaveCall"); // and end the main span to indicate we've left - if (this.callMembershipSpan) this.callMembershipSpan.end(); + this.callMembershipSpan?.end(); } public onUpdateRoomState(event: MatrixEvent) { @@ -177,4 +196,65 @@ export class OTelGroupCallMembership { "matrix.screensharing.enabled": newValue, }); } + + public onConnectionStatsReport( + statsReport: GroupCallStatsReport + ) { + const type = OTelStatsReportType.ConnectionStatsReport; + const data = + ObjectFlattener.flattenConnectionStatsReportObject(statsReport); + this.buildStatsEventSpan({ type, data }); + } + + public onByteSentStatsReport( + statsReport: GroupCallStatsReport + ) { + const type = OTelStatsReportType.ByteSentStatsReport; + const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport); + this.buildStatsEventSpan({ type, data }); + } + + private buildStatsEventSpan(event: OTelStatsReportEvent): void { + if (this.statsReportSpan.span === undefined && this.callMembershipSpan) { + const ctx = setSpan( + opentelemetry.context.active(), + this.callMembershipSpan + ); + this.statsReportSpan.span = + ElementCallOpenTelemetry.instance.tracer.startSpan( + "matrix.groupCallMembership.statsReport", + undefined, + ctx + ); + this.statsReportSpan.span.setAttribute( + "matrix.confId", + this.groupCall.groupCallId + ); + this.statsReportSpan.span.setAttribute("matrix.userId", this.myUserId); + this.statsReportSpan.span.setAttribute( + "matrix.displayName", + this.myMember ? this.myMember.name : "unknown-name" + ); + + this.statsReportSpan.span.addEvent(event.type, event.data); + this.statsReportSpan.stats.push(event); + } else if ( + this.statsReportSpan.span !== undefined && + this.callMembershipSpan + ) { + this.statsReportSpan.span.addEvent(event.type, event.data); + this.statsReportSpan.span.end(); + this.statsReportSpan = { span: undefined, stats: [] }; + } + } +} + +interface OTelStatsReportEvent { + type: OTelStatsReportType; + data: Attributes; +} + +enum OTelStatsReportType { + ConnectionStatsReport = "matrix.stats.connection", + ByteSentStatsReport = "matrix.stats.byteSent", } diff --git a/src/otel/ObjectFlattener.ts b/src/otel/ObjectFlattener.ts new file mode 100644 index 0000000..d45360c --- /dev/null +++ b/src/otel/ObjectFlattener.ts @@ -0,0 +1,83 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { Attributes } from "@opentelemetry/api"; +import { GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall"; +import { + ByteSentStatsReport, + ConnectionStatsReport, +} from "matrix-js-sdk/src/webrtc/stats/statsReport"; + +export class ObjectFlattener { + public static flattenConnectionStatsReportObject( + statsReport: GroupCallStatsReport + ): Attributes { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive( + statsReport.report, + flatObject, + "matrix.stats.conn.", + 0 + ); + return flatObject; + } + + public static flattenByteSentStatsReportObject( + statsReport: GroupCallStatsReport + ): Attributes { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive( + statsReport.report, + flatObject, + "matrix.stats.bytesSent.", + 0 + ); + return flatObject; + } + + public static flattenObjectRecursive( + obj: Object, + flatObject: Attributes, + prefix: string, + depth: number + ): void { + if (depth > 10) + throw new Error( + "Depth limit exceeded: aborting VoipEvent recursion. Prefix is " + + prefix + ); + let entries; + if (obj instanceof Map) { + entries = obj.entries(); + } else { + entries = Object.entries(obj); + } + for (const [k, v] of entries) { + if (["string", "number", "boolean"].includes(typeof v) || v === null) { + let value; + value = v === null ? "null" : v; + value = typeof v === "number" && Number.isNaN(v) ? "NaN" : value; + flatObject[prefix + k] = value; + } else if (typeof v === "object") { + ObjectFlattener.flattenObjectRecursive( + v, + flatObject, + prefix + k + ".", + depth + 1 + ); + } + } + } +} diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 0b54c82..1c69181 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -22,12 +22,18 @@ import { GroupCallErrorCode, GroupCallUnknownDeviceError, GroupCallError, + GroupCallStatsReportEvent, + GroupCallStatsReport, } from "matrix-js-sdk/src/webrtc/groupCall"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { useTranslation } from "react-i18next"; import { IWidgetApiRequest } from "matrix-widget-api"; import { MatrixClient } from "matrix-js-sdk"; +import { + ByteSentStatsReport, + ConnectionStatsReport, +} from "matrix-js-sdk/src/webrtc/stats/statsReport"; import { usePageUnload } from "./usePageUnload"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; @@ -330,6 +336,18 @@ export function useGroupCall( } } + function onConnectionStatsReport( + report: GroupCallStatsReport + ): void { + groupCallOTelMembership?.onConnectionStatsReport(report); + } + + function onByteSentStatsReport( + report: GroupCallStatsReport + ): void { + groupCallOTelMembership?.onByteSentStatsReport(report); + } + groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged); groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged); groupCall.on( @@ -346,6 +364,16 @@ export function useGroupCall( groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); groupCall.on(GroupCallEvent.Error, onError); + groupCall.on( + GroupCallStatsReportEvent.ConnectionStats, + onConnectionStatsReport + ); + + groupCall.on( + GroupCallStatsReportEvent.ByteSentStats, + onByteSentStatsReport + ); + updateState({ error: null, state: groupCall.state, @@ -392,6 +420,14 @@ export function useGroupCall( onParticipantsChanged ); groupCall.removeListener(GroupCallEvent.Error, onError); + groupCall.removeListener( + GroupCallStatsReportEvent.ConnectionStats, + onConnectionStatsReport + ); + groupCall.removeListener( + GroupCallStatsReportEvent.ByteSentStats, + onByteSentStatsReport + ); groupCall.leave(); }; }, [groupCall, updateState]); diff --git a/test/otel/ObjectFlattene-test.ts b/test/otel/ObjectFlattene-test.ts new file mode 100644 index 0000000..a0258c0 --- /dev/null +++ b/test/otel/ObjectFlattene-test.ts @@ -0,0 +1,215 @@ +import { ObjectFlattener } from "../../src/otel/ObjectFlattener"; + +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +describe("ObjectFlattener", () => { + const statsReport = { + report: { + bandwidth: { upload: 426, download: 0 }, + bitrate: { + upload: 426, + download: 0, + audio: { + upload: 124, + download: 0, + }, + video: { + upload: 302, + download: 0, + }, + }, + packetLoss: { + total: 0, + download: 0, + upload: 0, + }, + framerate: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", 0], + ["LOCAL_VIDEO_TRACK_ID", 30], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", 0], + ["REMOTE_VIDEO_TRACK_ID", 60], + ]), + }, + resolution: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }], + ["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }], + ["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }], + ]), + }, + codec: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", "opus"], + ["LOCAL_VIDEO_TRACK_ID", "v8"], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", "opus"], + ["REMOTE_VIDEO_TRACK_ID", "v9"], + ]), + }, + transport: [ + { + ip: "ff11::5fa:abcd:999c:c5c5:50000", + type: "udp", + localIp: "2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000", + isFocus: true, + localCandidateType: "host", + remoteCandidateType: "host", + networkType: "ethernet", + rtt: NaN, + }, + { + ip: "10.10.10.2:22222", + type: "tcp", + localIp: "10.10.10.100:33333", + isFocus: true, + localCandidateType: "srfx", + remoteCandidateType: "srfx", + networkType: "ethernet", + rtt: null, + }, + ], + }, + }; + describe("on flattenObjectRecursive", () => { + it("should flatter an Map object", () => { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive( + statsReport.report.resolution, + flatObject, + "matrix.stats.conn.resolution.", + 0 + ); + expect(flatObject).toEqual({ + "matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1, + "matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1, + + "matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460, + "matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780, + + "matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1, + "matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1, + + "matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960, + "matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080, + }); + }); + it("should flatter an Array object", () => { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive( + statsReport.report.transport, + flatObject, + "matrix.stats.conn.transport.", + 0 + ); + expect(flatObject).toEqual({ + "matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000", + "matrix.stats.conn.transport.0.type": "udp", + "matrix.stats.conn.transport.0.localIp": + "2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000", + "matrix.stats.conn.transport.0.isFocus": true, + "matrix.stats.conn.transport.0.localCandidateType": "host", + "matrix.stats.conn.transport.0.remoteCandidateType": "host", + "matrix.stats.conn.transport.0.networkType": "ethernet", + "matrix.stats.conn.transport.0.rtt": "NaN", + "matrix.stats.conn.transport.1.ip": "10.10.10.2:22222", + "matrix.stats.conn.transport.1.type": "tcp", + "matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333", + "matrix.stats.conn.transport.1.isFocus": true, + "matrix.stats.conn.transport.1.localCandidateType": "srfx", + "matrix.stats.conn.transport.1.remoteCandidateType": "srfx", + "matrix.stats.conn.transport.1.networkType": "ethernet", + "matrix.stats.conn.transport.1.rtt": "null", + }); + }); + }); + + describe("on flattenConnectionStatsReportObject", () => { + it("should flatten a Report to otel Attributes Object", () => { + expect( + ObjectFlattener.flattenConnectionStatsReportObject(statsReport) + ).toEqual({ + "matrix.stats.conn.bandwidth.download": 0, + "matrix.stats.conn.bandwidth.upload": 426, + "matrix.stats.conn.bitrate.audio.download": 0, + "matrix.stats.conn.bitrate.audio.upload": 124, + "matrix.stats.conn.bitrate.download": 0, + "matrix.stats.conn.bitrate.upload": 426, + "matrix.stats.conn.bitrate.video.download": 0, + "matrix.stats.conn.bitrate.video.upload": 302, + "matrix.stats.conn.codec.local.LOCAL_AUDIO_TRACK_ID": "opus", + "matrix.stats.conn.codec.local.LOCAL_VIDEO_TRACK_ID": "v8", + "matrix.stats.conn.codec.remote.REMOTE_AUDIO_TRACK_ID": "opus", + "matrix.stats.conn.codec.remote.REMOTE_VIDEO_TRACK_ID": "v9", + "matrix.stats.conn.framerate.local.LOCAL_AUDIO_TRACK_ID": 0, + "matrix.stats.conn.framerate.local.LOCAL_VIDEO_TRACK_ID": 30, + "matrix.stats.conn.framerate.remote.REMOTE_AUDIO_TRACK_ID": 0, + "matrix.stats.conn.framerate.remote.REMOTE_VIDEO_TRACK_ID": 60, + "matrix.stats.conn.packetLoss.download": 0, + "matrix.stats.conn.packetLoss.total": 0, + "matrix.stats.conn.packetLoss.upload": 0, + "matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1, + "matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1, + "matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460, + "matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780, + "matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1, + "matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1, + "matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960, + "matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080, + "matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000", + "matrix.stats.conn.transport.0.type": "udp", + "matrix.stats.conn.transport.0.localIp": + "2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000", + "matrix.stats.conn.transport.0.isFocus": true, + "matrix.stats.conn.transport.0.localCandidateType": "host", + "matrix.stats.conn.transport.0.remoteCandidateType": "host", + "matrix.stats.conn.transport.0.networkType": "ethernet", + "matrix.stats.conn.transport.0.rtt": "NaN", + "matrix.stats.conn.transport.1.ip": "10.10.10.2:22222", + "matrix.stats.conn.transport.1.type": "tcp", + "matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333", + "matrix.stats.conn.transport.1.isFocus": true, + "matrix.stats.conn.transport.1.localCandidateType": "srfx", + "matrix.stats.conn.transport.1.remoteCandidateType": "srfx", + "matrix.stats.conn.transport.1.networkType": "ethernet", + "matrix.stats.conn.transport.1.rtt": "null", + }); + }); + }); + + describe("on flattenByteSendStatsReportObject", () => { + const byteSent = { + report: new Map([ + ["4aa92608-04c6-428e-8312-93e17602a959", 132093], + ["a08e4237-ee30-4015-a932-b676aec894b1", 913448], + ]), + }; + it("should flatten a Report to otel Attributes Object", () => { + expect( + ObjectFlattener.flattenByteSentStatsReportObject(byteSent) + ).toEqual({ + "matrix.stats.bytesSent.4aa92608-04c6-428e-8312-93e17602a959": 132093, + "matrix.stats.bytesSent.a08e4237-ee30-4015-a932-b676aec894b1": 913448, + }); + }); + }); +});