Merge branch 'main' into audio-observability

This commit is contained in:
Robin Townsend 2023-04-05 12:50:38 -04:00
commit 711cdf9a60
29 changed files with 1014 additions and 184 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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:
- "*"

View file

@ -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",

View file

@ -8,6 +8,7 @@
"{{name}} is talking…": "{{name}} is talking…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call",
"<0></0><1></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></0><1></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?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>",
@ -21,7 +22,7 @@
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>",
"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</2> and our <5>Cookie Policy</5>.": "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</2> and our <5>Cookie Policy</5>.",
"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",

View file

@ -342,6 +342,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
useEffect(() => {
window.matrixclient = client;
window.isPasswordlessUser = isPasswordlessUser;
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
}, [client, isPasswordlessUser]);
if (error) {

View file

@ -0,0 +1,14 @@
import React, { FC } from "react";
import { Trans } from "react-i18next";
import { Link } from "../typography/Typography";
export const AnalyticsNotice: FC = () => (
<Trans>
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{" "}
<Link href="https://element.io/privacy">Privacy Policy</Link> and our{" "}
<Link href="https://element.io/cookie-policy">Cookie Policy</Link>.
</Trans>
);

View file

@ -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 "
)}
</>
<Link color="primary" href="https://element.io/privacy">
<>{t("Privacy Policy")}</>
</Link>
.
</>
);
};

View file

@ -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<void> {
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<void> {
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<void> {
console.log("POSTHOGEXPORTER shutdown of otelPosthogExporter");
return new Promise<void>((resolve, _reject) => {
resolve();
});
return Promise.resolve();
}
}

View file

@ -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<void> {
// 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<void> {
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

View file

@ -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;

View file

@ -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<HTMLFormElement>;
children: JSX.Element[];
children: ReactNode[];
}
export const Form = forwardRef<HTMLFormElement, FormProps>(

View file

@ -37,3 +37,7 @@ limitations under the License.
.recentCallsTitle {
margin-bottom: 32px;
}
.notice {
color: var(--secondary-content);
}

View file

@ -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<Error>();
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")}
</Button>
</FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
{optInAnalytics === null && (
<Caption className={styles.notice}>
<AnalyticsNotice />
</Caption>
)}
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage error={error} />

View file

@ -45,3 +45,7 @@ limitations under the License.
display: none;
}
}
.notice {
color: var(--secondary-content);
}

View file

@ -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<Error>();
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"
/>
</FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
<Caption>
{optInAnalytics === null && (
<Caption className={styles.notice}>
<AnalyticsNotice />
</Caption>
)}
<Caption className={styles.notice}>
<Trans>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>

119
src/otel/OTelCall.ts Normal file
View file

@ -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);
};
}

View file

@ -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<string, OTelCall>();
private statsReportSpan: {
span: Span | undefined;
stats: OTelStatsReportEvent[];
};
private readonly speakingSpans = new Map<RoomMember, Map<string, Span>>();
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<ConnectionStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
const type = OTelStatsReportType.ConnectionReport;
const data =
ObjectFlattener.flattenConnectionStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
}
public onByteSentStatsReport(
statsReport: GroupCallStatsReport<ByteSentStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
const type = OTelStatsReportType.ByteSentReport;
const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
}
public onSummaryStatsReport(
statsReport: GroupCallStatsReport<SummaryStatsReport>
) {
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",
}

View file

@ -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<ConnectionStatsReport>
): Attributes {
const flatObject = {};
ObjectFlattener.flattenObjectRecursive(
statsReport.report,
flatObject,
"matrix.stats.conn.",
0
);
return flatObject;
}
public static flattenByteSentStatsReportObject(
statsReport: GroupCallStatsReport<ByteSentStatsReport>
): Attributes {
const flatObject = {};
ObjectFlattener.flattenObjectRecursive(
statsReport.report,
flatObject,
"matrix.stats.bytesSent.",
0
);
return flatObject;
}
static flattenSummaryStatsReportObject(
statsReport: GroupCallStatsReport<SummaryStatsReport>
) {
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
);
}
}
}
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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

View file

@ -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<ConnectionStatsReport>
): void {
groupCallOTelMembership?.onConnectionStatsReport(report);
}
function onByteSentStatsReport(
report: GroupCallStatsReport<ByteSentStatsReport>
): void {
groupCallOTelMembership?.onByteSentStatsReport(report);
}
function onSummaryStatsReport(
report: GroupCallStatsReport<SummaryStatsReport>
): 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,

View file

@ -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;
}

View file

@ -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 = (
<Caption>
<Trans>
<AnalyticsNotice />
<br />
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.
</Trans>
</Caption>
);
return (
<Modal
title={t("Settings")}
@ -122,16 +132,16 @@ export const SettingsModal = (props: Props) => {
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<HTMLInputElement>) =>
setSpatialAudio(event.target.checked)
setSpatialAudio!(event.target.checked)
}
/>
</FieldRow>
@ -187,7 +197,7 @@ export const SettingsModal = (props: Props) => {
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}

View file

@ -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> = [T, (value: T) => void];
type DisableableSetting<T> = [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 = <T>(
name: string,
defaultValue: T
): [T, (value: T) => void] => {
const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
const key = useMemo(() => getSettingKey(name), [name]);
const [value, setValue] = useState<T>(() => {
@ -65,7 +67,7 @@ export const setSetting = <T>(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<boolean> => {
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<boolean | null> => {
const settingVal = useSetting<boolean | null>("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);

View file

@ -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 {

View file

@ -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,
});
});
});
});

View file

@ -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"