diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f49980d..3558797 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -40,6 +40,9 @@ jobs: SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} VITE_APP_VERSION: ${{ github.event.release.tag_name }} + # This appears to be necessary to stop Vite from OOMing + # https://github.com/vitejs/vite/issues/2433 + NODE_OPTIONS: "--max-old-space-size=16384" - name: Create Tarball env: diff --git a/config/otel_dev/README.md b/config/otel_dev/README.md index 8fe102d..ea6c09a 100644 --- a/config/otel_dev/README.md +++ b/config/otel_dev/README.md @@ -1,7 +1,7 @@ # OpenTelemetry Collector for development This directory contains a docker compose file that starts a jaeger all-in-one instance -with an in-memory database, along with a standalong OpenTelemetry collector that forwards +with an in-memory database, along with a standalone OpenTelemetry collector that forwards traces into the jaeger. Jaeger has a built-in OpenTelemetry collector, but it can't be configured to send CORS headers so can't be used from a browser. This sets the config on the collector to send CORS headers. diff --git a/config/otel_dev/collector-gateway.yaml b/config/otel_dev/collector-gateway.yaml index 6cc85a0..f9e3b90 100644 --- a/config/otel_dev/collector-gateway.yaml +++ b/config/otel_dev/collector-gateway.yaml @@ -5,6 +5,9 @@ receivers: endpoint: 0.0.0.0:4318 cors: allowed_origins: + # This can't be '*' because opentelemetry-js uses sendBeacon which always operates + # in 'withCredentials' mode, which browsers don't allow with an allow-origin of '*' + #- "https://pr976--element-call.netlify.app" - "http://*" allowed_headers: - "*" diff --git a/package.json b/package.json index a32fd8c..a8e5bfc 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "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-widget-api": "^1.0.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#e89467c9fbf98182def2088a947155f30fdc7d1f", + "matrix-widget-api": "^1.3.1", "mermaid": "^8.13.8", "normalize.css": "^8.0.1", "pako": "^2.0.4", diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index fe52c85..389eeb7 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -8,6 +8,7 @@ "{{name}} is talking…": "{{name}} is talking…", "{{names}}, {{name}}": "{{names}}, {{name}}", "{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call", + "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Already have an account?<1><0>Log in Or <2>Access as a guest", "<0>Create an account Or <2>Access as a guest": "<0>Create an account Or <2>Access as a guest", "<0>Join call now<1>Or<2>Copy call link and join later": "<0>Join call now<1>Or<2>Copy call link and join later", @@ -21,7 +22,7 @@ "Avatar": "Avatar", "By clicking \"Go\", you agree to our <2>Terms and conditions": "By clicking \"Go\", you agree to our <2>Terms and conditions", "By clicking \"Join call now\", you agree to our <2>Terms and conditions": "By clicking \"Join call now\", you agree to our <2>Terms and conditions", - "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ": "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ", + "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy and our <5>Cookie Policy.": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy and our <5>Cookie Policy.", "Call link copied": "Call link copied", "Call type menu": "Call type menu", "Camera": "Camera", @@ -85,7 +86,6 @@ "Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}", "Press and hold to talk": "Press and hold to talk", "Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}", - "Privacy Policy": "Privacy Policy", "Profile": "Profile", "Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha not loaded": "Recaptcha not loaded", diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 46dfe5a..08593a4 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -342,6 +342,9 @@ export const ClientProvider: FC = ({ children }) => { useEffect(() => { window.matrixclient = client; window.isPasswordlessUser = isPasswordlessUser; + + if (PosthogAnalytics.hasInstance()) + PosthogAnalytics.instance.onLoginStatusChanged(); }, [client, isPasswordlessUser]); if (error) { diff --git a/src/analytics/AnalyticsNotice.tsx b/src/analytics/AnalyticsNotice.tsx new file mode 100644 index 0000000..feceef7 --- /dev/null +++ b/src/analytics/AnalyticsNotice.tsx @@ -0,0 +1,14 @@ +import React, { FC } from "react"; +import { Trans } from "react-i18next"; + +import { Link } from "../typography/Typography"; + +export const AnalyticsNotice: FC = () => ( + + By participating in this beta, you consent to the collection of anonymous + data, which we use to improve the product. You can find more information + about which data we track in our{" "} + Privacy Policy and our{" "} + Cookie Policy. + +); diff --git a/src/analytics/AnalyticsOptInDescription.tsx b/src/analytics/AnalyticsOptInDescription.tsx deleted file mode 100644 index 46727f5..0000000 --- a/src/analytics/AnalyticsOptInDescription.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { t } from "i18next"; -import React from "react"; - -import { Link } from "../typography/Typography"; - -export const optInDescription: () => JSX.Element = () => { - return ( - <> - <> - {t( - "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our " - )} - - - <>{t("Privacy Policy")} - - . - - ); -}; diff --git a/src/analytics/OtelPosthogExporter.ts b/src/analytics/OtelPosthogExporter.ts index 4fbfa90..c624a00 100644 --- a/src/analytics/OtelPosthogExporter.ts +++ b/src/analytics/OtelPosthogExporter.ts @@ -16,11 +16,29 @@ limitations under the License. import { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { ExportResult, ExportResultCode } from "@opentelemetry/core"; +import { logger } from "matrix-js-sdk/src/logger"; +import { HrTime } from "@opentelemetry/api"; import { PosthogAnalytics } from "./PosthogAnalytics"; + +interface PrevCall { + callId: string; + hangupTs: number; +} + +function hrTimeToMs(time: HrTime): number { + return time[0] * 1000 + time[1] * 0.000001; +} + /** - * This is implementation of {@link SpanExporter} that prints spans to the - * console. This class can be used for diagnostic purposes. + * The maximum time between hanging up and joining the same call that we would + * consider a 'rejoin' on the user's part. + */ +const maxRejoinMs = 2 * 60 * 1000; // 2 minutes + +/** + * This is implementation of {@link SpanExporter} that extracts certain metrics + * from spans to send to PostHog */ export class PosthogSpanExporter implements SpanExporter { /** @@ -32,41 +50,68 @@ export class PosthogSpanExporter implements SpanExporter { spans: ReadableSpan[], resultCallback: (result: ExportResult) => void ): Promise { - console.log("POSTHOGEXPORTER", spans); - for (const span of spans) { - const sendInstantly = [ - "otel_callEnded", - "otel_otherSentInstantlyEventName", - ].includes(span.name); - - for (const spanEvent of span.events) { - await PosthogAnalytics.instance.trackFromSpan( - { - eventName: spanEvent.name, - ...spanEvent.attributes, - }, - { - send_instantly: sendInstantly, - } - ); - } - - await PosthogAnalytics.instance.trackFromSpan( - { eventName: span.name, ...span.attributes }, - { - send_instantly: sendInstantly, + await Promise.all( + spans.map((span) => { + switch (span.name) { + case "matrix.groupCallMembership": + return this.exportGroupCallMembershipSpan(span); + // TBD if there are other spans that we want to process for export to + // PostHog } - ); - resultCallback({ code: ExportResultCode.SUCCESS }); + }) + ); + + resultCallback({ code: ExportResultCode.SUCCESS }); + } + + private get prevCall(): PrevCall | null { + // This is stored in localStorage so we can remember the previous call + // across app restarts + const data = localStorage.getItem("matrix-prev-call"); + if (data === null) return null; + + try { + return JSON.parse(data); + } catch (e) { + logger.warn("Invalid prev call data", data); + return null; } } + + private set prevCall(data: PrevCall | null) { + localStorage.setItem("matrix-prev-call", JSON.stringify(data)); + } + + async exportGroupCallMembershipSpan(span: ReadableSpan): Promise { + const prevCall = this.prevCall; + const newPrevCall = (this.prevCall = { + callId: span.attributes["matrix.confId"] as string, + hangupTs: hrTimeToMs(span.endTime), + }); + + // If the user joined the same call within a short time frame, log this as a + // rejoin. This is interesting as a call quality metric, since rejoins may + // indicate that users had to intervene to make the product work. + if (prevCall !== null && newPrevCall.callId === prevCall.callId) { + const duration = hrTimeToMs(span.startTime) - prevCall.hangupTs; + if (duration <= maxRejoinMs) { + PosthogAnalytics.instance.trackEvent( + { + eventName: "Rejoin", + callId: prevCall.callId, + rejoinDuration: duration, + }, + // Send instantly because the window might be closing + { send_instantly: true } + ); + } + } + } + /** * Shutdown the exporter. */ shutdown(): Promise { - console.log("POSTHOGEXPORTER shutdown of otelPosthogExporter"); - return new Promise((resolve, _reject) => { - resolve(); - }); + return Promise.resolve(); } } diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 718a49c..ed8ada3 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -102,6 +102,10 @@ export class PosthogAnalytics { private platformSuperProperties = {}; private registrationType: RegistrationType = RegistrationType.Guest; + public static hasInstance(): boolean { + return Boolean(this.internalInstance); + } + public static get instance(): PosthogAnalytics { if (!this.internalInstance) { this.internalInstance = new PosthogAnalytics(posthog); @@ -227,7 +231,7 @@ export class PosthogAnalytics { .join(""); } - public async identifyUser(analyticsIdGenerator: () => string) { + private async identifyUser(analyticsIdGenerator: () => string) { if (this.anonymity == Anonymity.Pseudonymous && this.enabled) { // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows // different devices to send the same ID. @@ -319,7 +323,12 @@ export class PosthogAnalytics { this.setAnonymity(Anonymity.Disabled); } - public updateSuperProperties() { + public onLoginStatusChanged(): void { + const optInAnalytics = getSetting("opt-in-analytics", false); + this.updateAnonymityAndIdentifyUser(optInAnalytics); + } + + private updateSuperProperties() { // Update super properties in posthog with our platform (app version, platform). // These properties will be subsequently passed in every event. // @@ -339,7 +348,7 @@ export class PosthogAnalytics { return this.eventSignup.getSignupEndTime() > new Date(0); } - public async updateAnonymityAndIdentifyUser( + private async updateAnonymityAndIdentifyUser( pseudonymousOptIn: boolean ): Promise { // Update this.anonymity based on the user's analytics opt-in settings @@ -348,6 +357,10 @@ export class PosthogAnalytics { : Anonymity.Disabled; this.setAnonymity(anonymity); + // We may not yet have a Matrix client at this point, if not, bail. This should get + // triggered again by onLoginStatusChanged once we do have a client. + if (!window.matrixclient) return; + if (anonymity === Anonymity.Pseudonymous) { this.setRegistrationType( window.matrixclient.isGuest() || window.isPasswordlessUser @@ -385,23 +398,7 @@ export class PosthogAnalytics { this.capture(eventName, properties, options); } - public async trackFromSpan( - { eventName, ...properties }, - options?: CaptureOptions - ): Promise { - if (this.identificationPromise) { - // only make calls to posthog after the identificaion is done - await this.identificationPromise; - } - if ( - this.anonymity == Anonymity.Disabled || - this.anonymity == Anonymity.Anonymous - ) - return; - this.capture(eventName, properties, options); - } - - public startListeningToSettingsChanges(): void { + private startListeningToSettingsChanges(): void { // Listen to account data changes from sync so we can observe changes to relevant flags and update. // This is called - // * On page load, when the account data is first received by sync diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index cfaaf77..8b0fae1 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -37,7 +37,8 @@ export interface ConfigOptions { }; /** - * Controls whether to to send OpenTelemetry debugging data to collector + * Sets the URL to send opentelemetry data to. If unset, opentelemetry will + * be disabled. */ opentelemetry?: { collector_url: string; diff --git a/src/form/Form.tsx b/src/form/Form.tsx index beea4c8..fd98a62 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -15,14 +15,14 @@ limitations under the License. */ import classNames from "classnames"; -import React, { FormEventHandler, forwardRef } from "react"; +import React, { FormEventHandler, forwardRef, ReactNode } from "react"; import styles from "./Form.module.css"; interface FormProps { className: string; onSubmit: FormEventHandler; - children: JSX.Element[]; + children: ReactNode[]; } export const Form = forwardRef( diff --git a/src/home/RegisteredView.module.css b/src/home/RegisteredView.module.css index ae43b5b..a96bd53 100644 --- a/src/home/RegisteredView.module.css +++ b/src/home/RegisteredView.module.css @@ -37,3 +37,7 @@ limitations under the License. .recentCallsTitle { margin-bottom: 32px; } + +.notice { + color: var(--secondary-content); +} diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index a6278c2..06a6720 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -39,11 +39,11 @@ import { CallList } from "./CallList"; import { UserMenuContainer } from "../UserMenuContainer"; import { useModalTriggerState } from "../Modal"; import { JoinExistingCallModal } from "./JoinExistingCallModal"; -import { Title } from "../typography/Typography"; +import { Caption, Title } from "../typography/Typography"; import { Form } from "../form/Form"; import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import { useOptInAnalytics } from "../settings/useSetting"; -import { optInDescription } from "../analytics/AnalyticsOptInDescription"; +import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; interface Props { client: MatrixClient; @@ -54,7 +54,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { const [callType, setCallType] = useState(CallType.Video); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); + const [optInAnalytics] = useOptInAnalytics(); const history = useHistory(); const { t } = useTranslation(); const { modalState, modalProps } = useModalTriggerState(); @@ -144,15 +144,11 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { {loading ? t("Loading…") : t("Go")} - ) => - setOptInAnalytics(event.target.checked) - } - /> + {optInAnalytics === null && ( + + + + )} {error && ( diff --git a/src/home/UnauthenticatedView.module.css b/src/home/UnauthenticatedView.module.css index bad173e..49c272b 100644 --- a/src/home/UnauthenticatedView.module.css +++ b/src/home/UnauthenticatedView.module.css @@ -45,3 +45,7 @@ limitations under the License. display: none; } } + +.notice { + color: var(--secondary-content); +} diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 6339e42..c31637b 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -39,15 +39,15 @@ import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import styles from "./UnauthenticatedView.module.css"; import commonStyles from "./common.module.css"; import { generateRandomName } from "../auth/generateRandomName"; +import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { useOptInAnalytics } from "../settings/useSetting"; -import { optInDescription } from "../analytics/AnalyticsOptInDescription"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); const [callType, setCallType] = useState(CallType.Video); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); + const [optInAnalytics] = useOptInAnalytics(); const [privacyPolicyUrl, recaptchaKey, register] = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); @@ -155,16 +155,12 @@ export const UnauthenticatedView: FC = () => { autoComplete="off" /> - ) => - setOptInAnalytics(event.target.checked) - } - /> - + {optInAnalytics === null && ( + + + + )} + By clicking "Go", you agree to our{" "} Terms and conditions diff --git a/src/otel/OTelCall.ts b/src/otel/OTelCall.ts new file mode 100644 index 0000000..79cc38d --- /dev/null +++ b/src/otel/OTelCall.ts @@ -0,0 +1,119 @@ +/* +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 { Span } from "@opentelemetry/api"; +import { MatrixCall } from "matrix-js-sdk"; +import { CallEvent } from "matrix-js-sdk/src/webrtc/call"; + +import { ObjectFlattener } from "./ObjectFlattener"; + +/** + * Tracks an individual call within a group call, either to a full-mesh peer or a focus + */ +export class OTelCall { + constructor( + public userId: string, + public deviceId: string, + public call: MatrixCall, + public span: Span + ) { + if (call.peerConn) { + this.addCallPeerConnListeners(); + } else { + this.call.once( + CallEvent.PeerConnectionCreated, + this.addCallPeerConnListeners + ); + } + } + + public dispose() { + this.call.peerConn.removeEventListener( + "connectionstatechange", + this.onCallConnectionStateChanged + ); + this.call.peerConn.removeEventListener( + "signalingstatechange", + this.onCallSignalingStateChanged + ); + this.call.peerConn.removeEventListener( + "iceconnectionstatechange", + this.onIceConnectionStateChanged + ); + this.call.peerConn.removeEventListener( + "icegatheringstatechange", + this.onIceGatheringStateChanged + ); + this.call.peerConn.removeEventListener( + "icecandidateerror", + this.onIceCandidateError + ); + } + + private addCallPeerConnListeners = (): void => { + this.call.peerConn.addEventListener( + "connectionstatechange", + this.onCallConnectionStateChanged + ); + this.call.peerConn.addEventListener( + "signalingstatechange", + this.onCallSignalingStateChanged + ); + this.call.peerConn.addEventListener( + "iceconnectionstatechange", + this.onIceConnectionStateChanged + ); + this.call.peerConn.addEventListener( + "icegatheringstatechange", + this.onIceGatheringStateChanged + ); + this.call.peerConn.addEventListener( + "icecandidateerror", + this.onIceCandidateError + ); + }; + + public onCallConnectionStateChanged = (): void => { + this.span.addEvent("matrix.call.callConnectionStateChange", { + callConnectionState: this.call.peerConn.connectionState, + }); + }; + + public onCallSignalingStateChanged = (): void => { + this.span.addEvent("matrix.call.callSignalingStateChange", { + callSignalingState: this.call.peerConn.signalingState, + }); + }; + + public onIceConnectionStateChanged = (): void => { + this.span.addEvent("matrix.call.iceConnectionStateChange", { + iceConnectionState: this.call.peerConn.iceConnectionState, + }); + }; + + public onIceGatheringStateChanged = (): void => { + this.span.addEvent("matrix.call.iceGatheringStateChange", { + iceGatheringState: this.call.peerConn.iceGatheringState, + }); + }; + + public onIceCandidateError = (ev: Event): void => { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive(ev, flatObject, "error.", 0); + + this.span.addEvent("matrix.call.iceCandidateError", flatObject); + }; +} diff --git a/src/otel/OTelGroupCallMembership.ts b/src/otel/OTelGroupCallMembership.ts index 3a00d43..d42573a 100644 --- a/src/otel/OTelGroupCallMembership.ts +++ b/src/otel/OTelGroupCallMembership.ts @@ -15,16 +15,35 @@ limitations under the License. */ import opentelemetry, { Span, Attributes, Context } from "@opentelemetry/api"; -import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; import { GroupCall, MatrixClient, MatrixEvent, RoomMember, } from "matrix-js-sdk"; -import { VoipEvent } from "matrix-js-sdk/src/webrtc/call"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + CallError, + CallState, + MatrixCall, + VoipEvent, +} from "matrix-js-sdk/src/webrtc/call"; +import { + CallsByUserAndDevice, + GroupCallError, + GroupCallEvent, + GroupCallStatsReport, +} from "matrix-js-sdk/src/webrtc/groupCall"; +import { + ConnectionStatsReport, + ByteSentStatsReport, + SummaryStatsReport, +} 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"; +import { OTelCall } from "./OTelCall"; /** * Flattens out an object into a single layer with components @@ -55,7 +74,7 @@ function flattenVoipEventRecursive( ); for (const [k, v] of Object.entries(obj)) { - if (["string", "number"].includes(typeof v)) { + if (["string", "number", "boolean"].includes(typeof v)) { flatObject[prefix + k] = v; } else if (typeof v === "object") { flattenVoipEventRecursive( @@ -73,21 +92,41 @@ function flattenVoipEventRecursive( */ export class OTelGroupCallMembership { private callMembershipSpan?: Span; - private callMembershipContext?: Context; - private myUserId: string; - private myMember: RoomMember; + private groupCallContext?: Context; + private myUserId = "unknown"; + private myDeviceId: string; + private myMember?: RoomMember; + private callsByCallId = new Map(); + private statsReportSpan: { + span: Span | undefined; + stats: OTelStatsReportEvent[]; + }; private readonly speakingSpans = new Map>(); 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.myDeviceId = client.getDeviceId() || "unknown"; + this.statsReportSpan = { span: undefined, stats: [] }; + this.groupCall.on(GroupCallEvent.CallsChanged, this.onCallsChanged); + } - ElementCallOpenTelemetry.instance.provider.resource.attributes[ - SemanticResourceAttributes.SERVICE_NAME - ] = `element-call-${this.myUserId}-${client.getDeviceId()}`; + dispose() { + this.groupCall.removeListener( + GroupCallEvent.CallsChanged, + this.onCallsChanged + ); } public onJoinCall() { + if (!ElementCallOpenTelemetry.instance) return; + // Create the main span that tracks the time we intend to be in the call this.callMembershipSpan = ElementCallOpenTelemetry.instance.tracer.startSpan( @@ -98,12 +137,13 @@ export class OTelGroupCallMembership { this.groupCall.groupCallId ); this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId); + this.callMembershipSpan.setAttribute("matrix.deviceId", this.myDeviceId); this.callMembershipSpan.setAttribute( "matrix.displayName", - this.myMember.name + this.myMember ? this.myMember.name : "unknown-name" ); - this.callMembershipContext = opentelemetry.trace.setSpan( + this.groupCallContext = opentelemetry.trace.setSpan( opentelemetry.context.active(), this.callMembershipSpan ); @@ -116,7 +156,7 @@ export class OTelGroupCallMembership { // and end the span to indicate we've left this.callMembershipSpan!.end(); this.callMembershipSpan = undefined; - this.callMembershipContext = undefined; + this.groupCallContext = undefined; } public onUpdateRoomState(event: MatrixEvent) { @@ -129,28 +169,111 @@ export class OTelGroupCallMembership { } this.callMembershipSpan?.addEvent( - `otel_onRoomStateEvent_${event.getType()}`, + `matrix.roomStateEvent_${event.getType()}`, flattenVoipEvent(event.getContent()) ); } - public onSendEvent(event: VoipEvent) { + public onCallsChanged = (calls: CallsByUserAndDevice) => { + for (const [userId, userCalls] of calls.entries()) { + for (const [deviceId, call] of userCalls.entries()) { + if (!this.callsByCallId.has(call.callId)) { + if (ElementCallOpenTelemetry.instance) { + const span = ElementCallOpenTelemetry.instance.tracer.startSpan( + `matrix.call`, + undefined, + this.groupCallContext + ); + // XXX: anonymity + span.setAttribute("matrix.call.target.userId", userId); + span.setAttribute("matrix.call.target.deviceId", deviceId); + const displayName = + this.groupCall.room.getMember(userId)?.name ?? "unknown"; + span.setAttribute("matrix.call.target.displayName", displayName); + this.callsByCallId.set( + call.callId, + new OTelCall(userId, deviceId, call, span) + ); + } + } + } + } + + for (const callTrackingInfo of this.callsByCallId.values()) { + const userCalls = calls.get(callTrackingInfo.userId); + if (!userCalls || !userCalls.has(callTrackingInfo.deviceId)) { + callTrackingInfo.span.end(); + this.callsByCallId.delete(callTrackingInfo.call.callId); + } + } + }; + + public onCallStateChange(call: MatrixCall, newState: CallState) { + const callTrackingInfo = this.callsByCallId.get(call.callId); + if (!callTrackingInfo) { + logger.error(`Got call state change for unknown call ID ${call.callId}`); + return; + } + + callTrackingInfo.span.addEvent("matrix.call.stateChange", { + state: newState, + }); + } + + public onSendEvent(call: MatrixCall, event: VoipEvent) { const eventType = event.eventType as string; if (!eventType.startsWith("m.call")) return; + const callTrackingInfo = this.callsByCallId.get(call.callId); + if (!callTrackingInfo) { + logger.error(`Got call send event for unknown call ID ${call.callId}`); + return; + } + if (event.type === "toDevice") { - this.callMembershipSpan?.addEvent( + callTrackingInfo.span.addEvent( `matrix.sendToDeviceEvent_${event.eventType}`, flattenVoipEvent(event) ); } else if (event.type === "sendEvent") { - this.callMembershipSpan?.addEvent( + callTrackingInfo.span.addEvent( `matrix.sendToRoomEvent_${event.eventType}`, flattenVoipEvent(event) ); } } + public onReceivedVoipEvent(event: MatrixEvent) { + // These come straight from CallEventHandler so don't have + // a call already associated (in principle we could receive + // events for calls we don't know about). + const callId = event.getContent().call_id; + if (!callId) { + this.callMembershipSpan?.addEvent("matrix.receive_voip_event_no_callid", { + "sender.userId": event.getSender(), + }); + logger.error("Received call event with no call ID!"); + return; + } + + const call = this.callsByCallId.get(callId); + if (!call) { + this.callMembershipSpan?.addEvent( + "matrix.receive_voip_event_unknown_callid", + { + "sender.userId": event.getSender(), + } + ); + logger.error("Received call event for unknown call ID " + callId); + return; + } + + call.span.addEvent("matrix.receive_voip_event", { + "sender.userId": event.getSender(), + ...flattenVoipEvent(event.getContent()), + }); + } + public onToggleMicrophoneMuted(newValue: boolean) { this.callMembershipSpan?.addEvent("matrix.toggleMicMuted", { "matrix.microphone.muted": newValue, @@ -194,7 +317,7 @@ export class OTelGroupCallMembership { const span = ElementCallOpenTelemetry.instance.tracer.startSpan( "matrix.audioActivity", undefined, - this.callMembershipContext + this.groupCallContext ); span.setAttribute("matrix.userId", member.userId); span.setAttribute("matrix.displayName", member.rawDisplayName); @@ -210,4 +333,106 @@ export class OTelGroupCallMembership { if (deviceMap?.size === 0) this.speakingSpans.delete(member); } } + + public onCallError(error: CallError, call: MatrixCall) { + const callTrackingInfo = this.callsByCallId.get(call.callId); + if (!callTrackingInfo) { + logger.error(`Got error for unknown call ID ${call.callId}`); + return; + } + + callTrackingInfo.span.recordException(error); + } + + public onGroupCallError(error: GroupCallError) { + this.callMembershipSpan?.recordException(error); + } + + public onUndecryptableToDevice(event: MatrixEvent) { + this.callMembershipSpan?.addEvent("matrix.toDevice.undecryptable", { + "sender.userId": event.getSender(), + }); + } + + public onConnectionStatsReport( + statsReport: GroupCallStatsReport + ) { + if (!ElementCallOpenTelemetry.instance) return; + + const type = OTelStatsReportType.ConnectionReport; + const data = + ObjectFlattener.flattenConnectionStatsReportObject(statsReport); + this.buildStatsEventSpan({ type, data }); + } + + public onByteSentStatsReport( + statsReport: GroupCallStatsReport + ) { + if (!ElementCallOpenTelemetry.instance) return; + + const type = OTelStatsReportType.ByteSentReport; + const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport); + this.buildStatsEventSpan({ type, data }); + } + + public onSummaryStatsReport( + statsReport: GroupCallStatsReport + ) { + if (!ElementCallOpenTelemetry.instance) return; + + const type = OTelStatsReportType.SummaryReport; + const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport); + this.buildStatsEventSpan({ type, data }); + } + + private buildStatsEventSpan(event: OTelStatsReportEvent): void { + // @ TODO: fix this - Because on multiple calls we receive multiple stats report spans. + // This could be break if stats arrived in same time from different call objects. + 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.stats.push(event); + // if received all three types of stats close this + if (this.statsReportSpan.stats.length === 3) { + this.statsReportSpan.span.end(); + this.statsReportSpan = { span: undefined, stats: [] }; + } + } + } +} + +interface OTelStatsReportEvent { + type: OTelStatsReportType; + data: Attributes; +} + +enum OTelStatsReportType { + ConnectionReport = "matrix.stats.connection", + ByteSentReport = "matrix.stats.byteSent", + SummaryReport = "matrix.stats.summary", } diff --git a/src/otel/ObjectFlattener.ts b/src/otel/ObjectFlattener.ts new file mode 100644 index 0000000..dcda078 --- /dev/null +++ b/src/otel/ObjectFlattener.ts @@ -0,0 +1,97 @@ +/* +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, + SummaryStatsReport, +} 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; + } + + static flattenSummaryStatsReportObject( + statsReport: GroupCallStatsReport + ) { + const flatObject = {}; + ObjectFlattener.flattenObjectRecursive( + statsReport.report, + flatObject, + "matrix.stats.summary.", + 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/otel/otel.ts b/src/otel/otel.ts index 25de3ac..d079da4 100644 --- a/src/otel/otel.ts +++ b/src/otel/otel.ts @@ -30,7 +30,7 @@ import { Anonymity } from "../analytics/PosthogAnalytics"; import { Config } from "../config/Config"; import { getSetting, settingsBus } from "../settings/useSetting"; -const SERVICE_NAME_BASE = "element-call"; +const SERVICE_NAME = "element-call"; let sharedInstance: ElementCallOpenTelemetry; @@ -48,7 +48,7 @@ export class ElementCallOpenTelemetry { return sharedInstance; } - constructor(collectorUrl: string) { + constructor(collectorUrl: string | undefined) { const otlpExporter = new OTLPTraceExporter({ url: collectorUrl, }); @@ -58,7 +58,7 @@ export class ElementCallOpenTelemetry { // This is how we can make Jaeger show a reaonsable service in the dropdown on the left. const providerConfig = { resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: `${SERVICE_NAME_BASE}-unauthenticated`, + [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, }), }; this._provider = new WebTracerProvider(providerConfig); @@ -88,12 +88,16 @@ export class ElementCallOpenTelemetry { } function recheckOTelEnabledStatus(optInAnalayticsEnabled: boolean): void { - if (optInAnalayticsEnabled && !sharedInstance) { + const shouldEnable = + optInAnalayticsEnabled && + Boolean(Config.get().opentelemetry?.collector_url); + + if (shouldEnable && !sharedInstance) { logger.info("Starting OpenTelemetry debug reporting"); sharedInstance = new ElementCallOpenTelemetry( Config.get().opentelemetry?.collector_url ); - } else if (!optInAnalayticsEnabled && sharedInstance) { + } else if (!shouldEnable && sharedInstance) { logger.info("Stopping OpenTelemetry debug reporting"); sharedInstance = undefined; } diff --git a/src/room/GroupCallInspector.tsx b/src/room/GroupCallInspector.tsx index c058297..ad67c7a 100644 --- a/src/room/GroupCallInspector.tsx +++ b/src/room/GroupCallInspector.tsx @@ -28,10 +28,20 @@ import ReactJson, { CollapsedFieldProps } from "react-json-view"; import mermaid from "mermaid"; import { Item } from "@react-stately/collections"; import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event"; -import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; +import { + GroupCall, + GroupCallError, + GroupCallEvent, +} from "matrix-js-sdk/src/webrtc/groupCall"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; -import { CallEvent, VoipEvent } from "matrix-js-sdk/src/webrtc/call"; +import { + CallEvent, + CallState, + CallError, + MatrixCall, + VoipEvent, +} from "matrix-js-sdk/src/webrtc/call"; import styles from "./GroupCallInspector.module.css"; import { SelectInput } from "../input/SelectInput"; @@ -388,26 +398,49 @@ function useGroupCallState( function onReceivedVoipEvent(event: MatrixEvent) { dispatch({ type: ClientEvent.ReceivedVoipEvent, event }); + + otelGroupCallMembership?.onReceivedVoipEvent(event); } - function onSendVoipEvent(event: VoipEvent) { + function onSendVoipEvent(event: VoipEvent, call: MatrixCall) { dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event }); - otelGroupCallMembership?.onSendEvent(event); + otelGroupCallMembership?.onSendEvent(call, event); + } + + function onCallStateChange( + newState: CallState, + _: CallState, + call: MatrixCall + ) { + otelGroupCallMembership?.onCallStateChange(call, newState); + } + + function onCallError(error: CallError, call: MatrixCall) { + otelGroupCallMembership.onCallError(error, call); + } + + function onGroupCallError(error: GroupCallError) { + otelGroupCallMembership.onGroupCallError(error); } function onUndecryptableToDevice(event: MatrixEvent) { dispatch({ type: ClientEvent.ReceivedVoipEvent, event }); Sentry.captureMessage("Undecryptable to-device Event"); + // probably unnecessary if it's now captured via otel? PosthogAnalytics.instance.eventUndecryptableToDevice.track( groupCall.groupCallId ); + + otelGroupCallMembership.onUndecryptableToDevice(event); } client.on(RoomStateEvent.Events, onUpdateRoomState); - //groupCall.on("calls_changed", onCallsChanged); groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent); + groupCall.on(CallEvent.State, onCallStateChange); + groupCall.on(CallEvent.Error, onCallError); + groupCall.on(GroupCallEvent.Error, onGroupCallError); //client.on("state", onCallsChanged); //client.on("hangup", onCallHangup); client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent); @@ -417,8 +450,10 @@ function useGroupCallState( return () => { client.removeListener(RoomStateEvent.Events, onUpdateRoomState); - //groupCall.removeListener("calls_changed", onCallsChanged); groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent); + groupCall.removeListener(CallEvent.State, onCallStateChange); + groupCall.removeListener(CallEvent.Error, onCallError); + groupCall.removeListener(GroupCallEvent.Error, onGroupCallError); //client.removeListener("state", onCallsChanged); //client.removeListener("hangup", onCallHangup); client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent); diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index f4e460d..fe91861 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -27,6 +27,7 @@ import { useUrlParams } from "../UrlParams"; import { MediaHandlerProvider } from "../settings/useMediaHandler"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { translatedError } from "../TranslatedError"; +import { useOptInAnalytics } from "../settings/useSetting"; export const RoomPage: FC = () => { const { t } = useTranslation(); @@ -46,9 +47,15 @@ export const RoomPage: FC = () => { const roomIdOrAlias = roomId ?? roomAlias; if (!roomIdOrAlias) throw translatedError("No room specified", t); + const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const [isRegistering, setIsRegistering] = useState(false); + useEffect(() => { + // During the beta, opt into analytics by default + if (optInAnalytics === null) setOptInAnalytics(true); + }, [optInAnalytics, setOptInAnalytics]); + useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 0b54c82..48b8d8f 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -22,12 +22,19 @@ 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, + SummaryStatsReport, +} from "matrix-js-sdk/src/webrtc/stats/statsReport"; import { usePageUnload } from "./usePageUnload"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; @@ -173,6 +180,8 @@ export function useGroupCall( }); if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) { + if (groupCallOTelMembership) groupCallOTelMembership.dispose(); + // If the user disables analytics, this will stay around until they leave the call // so analytics will be disabled once they leave. if (ElementCallOpenTelemetry.instance) { @@ -200,6 +209,11 @@ export function useGroupCall( [] ); + const leaveCall = useCallback(() => { + groupCallOTelMembership?.onLeaveCall(); + groupCall.leave(); + }, [groupCall]); + useEffect(() => { // disable the media action keys, otherwise audio elements get paused when // the user presses media keys or unplugs headphones, etc. @@ -214,7 +228,7 @@ export function useGroupCall( ]; for (const mediaAction of mediaActions) { - navigator.mediaSession.setActionHandler( + navigator.mediaSession?.setActionHandler( mediaAction, doNothingMediaActionCallback ); @@ -222,7 +236,7 @@ export function useGroupCall( return () => { for (const mediaAction of mediaActions) { - navigator.mediaSession.setActionHandler(mediaAction, null); + navigator.mediaSession?.setActionHandler(mediaAction, null); } }; }, [doNothingMediaActionCallback]); @@ -330,6 +344,24 @@ export function useGroupCall( } } + function onConnectionStatsReport( + report: GroupCallStatsReport + ): void { + groupCallOTelMembership?.onConnectionStatsReport(report); + } + + function onByteSentStatsReport( + report: GroupCallStatsReport + ): void { + groupCallOTelMembership?.onByteSentStatsReport(report); + } + + function onSummaryStatsReport( + report: GroupCallStatsReport + ): void { + groupCallOTelMembership?.onSummaryStatsReport(report); + } + groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged); groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged); groupCall.on( @@ -346,6 +378,18 @@ export function useGroupCall( groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); groupCall.on(GroupCallEvent.Error, onError); + groupCall.on( + GroupCallStatsReportEvent.ConnectionStats, + onConnectionStatsReport + ); + + groupCall.on( + GroupCallStatsReportEvent.ByteSentStats, + onByteSentStatsReport + ); + + groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport); + updateState({ error: null, state: groupCall.state, @@ -392,12 +436,24 @@ export function useGroupCall( onParticipantsChanged ); groupCall.removeListener(GroupCallEvent.Error, onError); - groupCall.leave(); + groupCall.removeListener( + GroupCallStatsReportEvent.ConnectionStats, + onConnectionStatsReport + ); + groupCall.removeListener( + GroupCallStatsReportEvent.ByteSentStats, + onByteSentStatsReport + ); + groupCall.removeListener( + GroupCallStatsReportEvent.SummaryStats, + onSummaryStatsReport + ); + leaveCall(); }; - }, [groupCall, updateState]); + }, [groupCall, updateState, leaveCall]); usePageUnload(() => { - groupCall.leave(); + leaveCall(); }); const initLocalCallFeed = useCallback( @@ -416,19 +472,16 @@ export function useGroupCall( PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId); + // This must be called before we start trying to join the call, as we need to + // have started tracking by the time calls start getting created. + groupCallOTelMembership?.onJoinCall(); + groupCall.enter().catch((error) => { console.error(error); updateState({ error }); }); - - groupCallOTelMembership?.onJoinCall(); }, [groupCall, updateState]); - const leave = useCallback(() => { - groupCallOTelMembership?.onLeaveCall(); - groupCall.leave(); - }, [groupCall]); - const toggleLocalVideoMuted = useCallback(() => { const toggleToMute = !groupCall.isLocalVideoMuted(); groupCall.setLocalVideoMuted(toggleToMute); @@ -561,7 +614,7 @@ export function useGroupCall( error, initLocalCallFeed, enter, - leave, + leave: leaveCall, toggleLocalVideoMuted, toggleMicrophoneMuted, toggleScreensharing, diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index 9b4951b..1e44dad 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -20,7 +20,7 @@ limitations under the License. } .tabContainer { - margin: 27px 16px; + padding: 27px 20px; } .fieldRowText { @@ -33,5 +33,5 @@ The "Developer" item in the tab bar can be toggled. Without a defined width activating the developer tab makes the tab container jump to the right. */ .tabLabel { - width: 80px; + min-width: 80px; } diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 90a1cb5..6e38b5f 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; import { Item } from "@react-stately/collections"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Modal } from "../Modal"; import styles from "./SettingsModal.module.css"; @@ -32,15 +32,14 @@ import { useSpatialAudio, useShowInspector, useOptInAnalytics, - canEnableSpatialAudio, useNewGrid, useDeveloperSettingsTab, } from "./useSetting"; import { FieldRow, InputField } from "../input/Input"; import { Button } from "../button"; import { useDownloadDebugLog } from "./submit-rageshake"; -import { Body } from "../typography/Typography"; -import { optInDescription } from "../analytics/AnalyticsOptInDescription"; +import { Body, Caption } from "../typography/Typography"; +import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; interface Props { isOpen: boolean; @@ -71,6 +70,17 @@ export const SettingsModal = (props: Props) => { const downloadDebugLog = useDownloadDebugLog(); + const optInDescription = ( + + + +
+ You may withdraw consent by unchecking this box. If you are currently in + a call, this setting will take effect at the end of the call. +
+ + ); + return ( { label={t("Spatial audio")} type="checkbox" checked={spatialAudio} - disabled={!canEnableSpatialAudio()} + disabled={setSpatialAudio === null} description={ - canEnableSpatialAudio() - ? t( + setSpatialAudio === null + ? t("This feature is only supported on Firefox.") + : t( "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)" ) - : t("This feature is only supported on Firefox.") } onChange={(event: React.ChangeEvent) => - setSpatialAudio(event.target.checked) + setSpatialAudio!(event.target.checked) } /> @@ -187,7 +197,7 @@ export const SettingsModal = (props: Props) => { id="optInAnalytics" type="checkbox" checked={optInAnalytics} - description={optInDescription()} + description={optInDescription} onChange={(event: React.ChangeEvent) => setOptInAnalytics(event.target.checked) } diff --git a/src/settings/useSetting.ts b/src/settings/useSetting.ts index 756ac74..f2a2bfc 100644 --- a/src/settings/useSetting.ts +++ b/src/settings/useSetting.ts @@ -17,6 +17,11 @@ limitations under the License. import { EventEmitter } from "events"; import { useMemo, useState, useEffect, useCallback } from "react"; +import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; + +type Setting = [T, (value: T) => void]; +type DisableableSetting = [T, ((value: T) => void) | null]; + // Bus to notify other useSetting consumers when a setting is changed export const settingsBus = new EventEmitter(); @@ -24,10 +29,7 @@ const getSettingKey = (name: string): string => { return `matrix-setting-${name}`; }; // Like useState, but reads from and persists the value to localStorage -const useSetting = ( - name: string, - defaultValue: T -): [T, (value: T) => void] => { +const useSetting = (name: string, defaultValue: T): Setting => { const key = useMemo(() => getSettingKey(name), [name]); const [value, setValue] = useState(() => { @@ -65,7 +67,7 @@ export const setSetting = (name: string, newValue: T) => { settingsBus.emit(name, newValue); }; -export const canEnableSpatialAudio = () => { +const canEnableSpatialAudio = () => { const { userAgent } = navigator; // Spatial audio means routing audio through audio contexts. On Chrome, // this bypasses the AEC processor and so breaks echo cancellation. @@ -79,17 +81,27 @@ export const canEnableSpatialAudio = () => { return userAgent.includes("Firefox"); }; -export const useSpatialAudio = (): [boolean, (val: boolean) => void] => { +export const useSpatialAudio = (): DisableableSetting => { const settingVal = useSetting("spatial-audio", false); if (canEnableSpatialAudio()) return settingVal; - return [false, (_: boolean) => {}]; + return [false, null]; }; export const useShowInspector = () => useSetting("show-inspector", false); -export const useOptInAnalytics = () => useSetting("opt-in-analytics", false); + +// null = undecided +export const useOptInAnalytics = (): DisableableSetting => { + const settingVal = useSetting("opt-in-analytics", null); + if (PosthogAnalytics.instance.isEnabled()) return settingVal; + + return [false, null]; +}; + export const useKeyboardShortcuts = () => useSetting("keyboard-shortcuts", true); + export const useNewGrid = () => useSetting("new-grid", false); + export const useDeveloperSettingsTab = () => useSetting("developer-settings-tab", false); diff --git a/src/tabs/Tabs.module.css b/src/tabs/Tabs.module.css index bad7a0e..188747c 100644 --- a/src/tabs/Tabs.module.css +++ b/src/tabs/Tabs.module.css @@ -88,7 +88,9 @@ limitations under the License. .tabContainer { width: 100%; flex-direction: row; - margin: 27px 16px; + padding: 27px 20px; + box-sizing: border-box; + overflow: hidden; } .tabList { diff --git a/test/otel/ObjectFlattener-test.ts b/test/otel/ObjectFlattener-test.ts new file mode 100644 index 0000000..a0258c0 --- /dev/null +++ b/test/otel/ObjectFlattener-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, + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index ddb3fc6..84a1c95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1821,10 +1821,10 @@ resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0" integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw== -"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3": - version "0.1.0-alpha.5" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz#60ede2c43b9d808ba8cf46085a3b347b290d9658" - integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw== +"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.5": + version "0.1.0-alpha.6" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.6.tgz#c0bdb9ab0d30179b8ef744d1b4010b0ad0ab9c3a" + integrity sha512-7hMffzw7KijxDyyH/eUyTfrLeCQHuyU3kaPOKGhcl3DZ3vx7bCncqjGMGTnxNPoP23I6gosvKSbO+3wYOT24Xg== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14" @@ -5726,7 +5726,12 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@^1.0.4, content-type@~1.0.4: +content-type@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== @@ -10413,9 +10418,9 @@ log-symbols@^4.1.0: is-unicode-supported "^0.1.0" loglevel@^1.7.1: - version "1.8.0" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114" - integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA== + version "1.8.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" + integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg== long@^2.4.0: version "2.4.0" @@ -10545,27 +10550,27 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#23837266fca5ee799b51a722f7b8eefb2f5ac140": - version "23.5.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/23837266fca5ee799b51a722f7b8eefb2f5ac140" +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#e89467c9fbf98182def2088a947155f30fdc7d1f": + version "24.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e89467c9fbf98182def2088a947155f30fdc7d1f" dependencies: "@babel/runtime" "^7.12.5" - "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.3" + "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.5" another-json "^0.2.0" bs58 "^5.0.0" content-type "^1.0.4" loglevel "^1.7.1" matrix-events-sdk "0.0.1" - matrix-widget-api "^1.0.0" + matrix-widget-api "^1.3.1" p-retry "4" sdp-transform "^2.14.1" unhomoglyph "^1.0.6" uuid "9" -matrix-widget-api@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.1.1.tgz#d3fec45033d0cbc14387a38ba92dac4dbb1be962" - integrity sha512-gNSgmgSwvOsOcWK9k2+tOhEMYBiIMwX95vMZu0JqY7apkM02xrOzUBuPRProzN8CnbIALH7e3GAhatF6QCNvtA== +matrix-widget-api@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.3.1.tgz#e38f404c76bb15c113909505c1c1a5b4d781c2f5" + integrity sha512-+rN6vGvnXm+fn0uq9r2KWSL/aPtehD6ObC50jYmUcEfgo8CUpf9eUurmjbRlwZkWq3XHXFuKQBUCI9UzqWg37Q== dependencies: "@types/events" "^3.0.0" events "^3.2.0"