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_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_VERSION: ${{ github.event.release.tag_name }} 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 - name: Create Tarball
env: env:

View file

@ -1,7 +1,7 @@
# OpenTelemetry Collector for development # OpenTelemetry Collector for development
This directory contains a docker compose file that starts a jaeger all-in-one instance 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 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 configured to send CORS headers so can't be used from a browser. This sets the config on
the collector to send CORS headers. the collector to send CORS headers.

View file

@ -5,6 +5,9 @@ receivers:
endpoint: 0.0.0.0:4318 endpoint: 0.0.0.0:4318
cors: cors:
allowed_origins: 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://*" - "http://*"
allowed_headers: allowed_headers:
- "*" - "*"

View file

@ -53,8 +53,8 @@
"i18next-browser-languagedetector": "^6.1.8", "i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.4", "i18next-http-backend": "^1.4.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#23837266fca5ee799b51a722f7b8eefb2f5ac140", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#e89467c9fbf98182def2088a947155f30fdc7d1f",
"matrix-widget-api": "^1.0.0", "matrix-widget-api": "^1.3.1",
"mermaid": "^8.13.8", "mermaid": "^8.13.8",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pako": "^2.0.4", "pako": "^2.0.4",

View file

@ -8,6 +8,7 @@
"{{name}} is talking…": "{{name}} is talking…", "{{name}} is talking…": "{{name}} is talking…",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call", "{{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>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>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>", "<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", "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 \"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 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 link copied": "Call link copied",
"Call type menu": "Call type menu", "Call type menu": "Call type menu",
"Camera": "Camera", "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 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": "Press and hold to talk",
"Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}", "Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}",
"Privacy Policy": "Privacy Policy",
"Profile": "Profile", "Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded", "Recaptcha not loaded": "Recaptcha not loaded",

View file

@ -342,6 +342,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
useEffect(() => { useEffect(() => {
window.matrixclient = client; window.matrixclient = client;
window.isPasswordlessUser = isPasswordlessUser; window.isPasswordlessUser = isPasswordlessUser;
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
}, [client, isPasswordlessUser]); }, [client, isPasswordlessUser]);
if (error) { 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 { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base";
import { ExportResult, ExportResultCode } from "@opentelemetry/core"; import { ExportResult, ExportResultCode } from "@opentelemetry/core";
import { logger } from "matrix-js-sdk/src/logger";
import { HrTime } from "@opentelemetry/api";
import { PosthogAnalytics } from "./PosthogAnalytics"; 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 * The maximum time between hanging up and joining the same call that we would
* console. This class can be used for diagnostic purposes. * 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 { export class PosthogSpanExporter implements SpanExporter {
/** /**
@ -32,41 +50,68 @@ export class PosthogSpanExporter implements SpanExporter {
spans: ReadableSpan[], spans: ReadableSpan[],
resultCallback: (result: ExportResult) => void resultCallback: (result: ExportResult) => void
): Promise<void> { ): Promise<void> {
console.log("POSTHOGEXPORTER", spans); await Promise.all(
for (const span of spans) { spans.map((span) => {
const sendInstantly = [ switch (span.name) {
"otel_callEnded", case "matrix.groupCallMembership":
"otel_otherSentInstantlyEventName", return this.exportGroupCallMembershipSpan(span);
].includes(span.name); // TBD if there are other spans that we want to process for export to
// PostHog
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,
} }
); })
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 the exporter.
*/ */
shutdown(): Promise<void> { shutdown(): Promise<void> {
console.log("POSTHOGEXPORTER shutdown of otelPosthogExporter"); return Promise.resolve();
return new Promise<void>((resolve, _reject) => {
resolve();
});
} }
} }

View file

@ -102,6 +102,10 @@ export class PosthogAnalytics {
private platformSuperProperties = {}; private platformSuperProperties = {};
private registrationType: RegistrationType = RegistrationType.Guest; private registrationType: RegistrationType = RegistrationType.Guest;
public static hasInstance(): boolean {
return Boolean(this.internalInstance);
}
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this.internalInstance) { if (!this.internalInstance) {
this.internalInstance = new PosthogAnalytics(posthog); this.internalInstance = new PosthogAnalytics(posthog);
@ -227,7 +231,7 @@ export class PosthogAnalytics {
.join(""); .join("");
} }
public async identifyUser(analyticsIdGenerator: () => string) { private async identifyUser(analyticsIdGenerator: () => string) {
if (this.anonymity == Anonymity.Pseudonymous && this.enabled) { 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 // 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. // different devices to send the same ID.
@ -319,7 +323,12 @@ export class PosthogAnalytics {
this.setAnonymity(Anonymity.Disabled); 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). // Update super properties in posthog with our platform (app version, platform).
// These properties will be subsequently passed in every event. // These properties will be subsequently passed in every event.
// //
@ -339,7 +348,7 @@ export class PosthogAnalytics {
return this.eventSignup.getSignupEndTime() > new Date(0); return this.eventSignup.getSignupEndTime() > new Date(0);
} }
public async updateAnonymityAndIdentifyUser( private async updateAnonymityAndIdentifyUser(
pseudonymousOptIn: boolean pseudonymousOptIn: boolean
): Promise<void> { ): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings // Update this.anonymity based on the user's analytics opt-in settings
@ -348,6 +357,10 @@ export class PosthogAnalytics {
: Anonymity.Disabled; : Anonymity.Disabled;
this.setAnonymity(anonymity); 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) { if (anonymity === Anonymity.Pseudonymous) {
this.setRegistrationType( this.setRegistrationType(
window.matrixclient.isGuest() || window.isPasswordlessUser window.matrixclient.isGuest() || window.isPasswordlessUser
@ -385,23 +398,7 @@ export class PosthogAnalytics {
this.capture(eventName, properties, options); this.capture(eventName, properties, options);
} }
public async trackFromSpan( private startListeningToSettingsChanges(): void {
{ 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 {
// Listen to account data changes from sync so we can observe changes to relevant flags and update. // Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called - // This is called -
// * On page load, when the account data is first received by sync // * 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?: { opentelemetry?: {
collector_url: string; collector_url: string;

View file

@ -15,14 +15,14 @@ limitations under the License.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { FormEventHandler, forwardRef } from "react"; import React, { FormEventHandler, forwardRef, ReactNode } from "react";
import styles from "./Form.module.css"; import styles from "./Form.module.css";
interface FormProps { interface FormProps {
className: string; className: string;
onSubmit: FormEventHandler<HTMLFormElement>; onSubmit: FormEventHandler<HTMLFormElement>;
children: JSX.Element[]; children: ReactNode[];
} }
export const Form = forwardRef<HTMLFormElement, FormProps>( export const Form = forwardRef<HTMLFormElement, FormProps>(

View file

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

View file

@ -39,11 +39,11 @@ import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer"; import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Title } from "../typography/Typography"; import { Caption, Title } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useOptInAnalytics } from "../settings/useSetting"; import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@ -54,7 +54,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
@ -144,15 +144,11 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
{loading ? t("Loading…") : t("Go")} {loading ? t("Loading…") : t("Go")}
</Button> </Button>
</FieldRow> </FieldRow>
<InputField {optInAnalytics === null && (
id="optInAnalytics" <Caption className={styles.notice}>
type="checkbox" <AnalyticsNotice />
checked={optInAnalytics} </Caption>
description={optInDescription()} )}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
{error && ( {error && (
<FieldRow className={styles.fieldRow}> <FieldRow className={styles.fieldRow}>
<ErrorMessage error={error} /> <ErrorMessage error={error} />

View file

@ -45,3 +45,7 @@ limitations under the License.
display: none; 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 styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting"; import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
const [privacyPolicyUrl, recaptchaKey, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@ -155,16 +155,12 @@ export const UnauthenticatedView: FC = () => {
autoComplete="off" autoComplete="off"
/> />
</FieldRow> </FieldRow>
<InputField {optInAnalytics === null && (
id="optInAnalytics" <Caption className={styles.notice}>
type="checkbox" <AnalyticsNotice />
checked={optInAnalytics} </Caption>
description={optInDescription()} )}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => <Caption className={styles.notice}>
setOptInAnalytics(event.target.checked)
}
/>
<Caption>
<Trans> <Trans>
By clicking "Go", you agree to our{" "} By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link> <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 opentelemetry, { Span, Attributes, Context } from "@opentelemetry/api";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { import {
GroupCall, GroupCall,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
RoomMember, RoomMember,
} from "matrix-js-sdk"; } 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 { ElementCallOpenTelemetry } from "./otel";
import { ObjectFlattener } from "./ObjectFlattener";
import { OTelCall } from "./OTelCall";
/** /**
* Flattens out an object into a single layer with components * Flattens out an object into a single layer with components
@ -55,7 +74,7 @@ function flattenVoipEventRecursive(
); );
for (const [k, v] of Object.entries(obj)) { 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; flatObject[prefix + k] = v;
} else if (typeof v === "object") { } else if (typeof v === "object") {
flattenVoipEventRecursive( flattenVoipEventRecursive(
@ -73,21 +92,41 @@ function flattenVoipEventRecursive(
*/ */
export class OTelGroupCallMembership { export class OTelGroupCallMembership {
private callMembershipSpan?: Span; private callMembershipSpan?: Span;
private callMembershipContext?: Context; private groupCallContext?: Context;
private myUserId: string; private myUserId = "unknown";
private myMember: RoomMember; 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>>(); private readonly speakingSpans = new Map<RoomMember, Map<string, Span>>();
constructor(private groupCall: GroupCall, client: MatrixClient) { constructor(private groupCall: GroupCall, client: MatrixClient) {
this.myUserId = client.getUserId(); const clientId = client.getUserId();
this.myMember = groupCall.room.getMember(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[ dispose() {
SemanticResourceAttributes.SERVICE_NAME this.groupCall.removeListener(
] = `element-call-${this.myUserId}-${client.getDeviceId()}`; GroupCallEvent.CallsChanged,
this.onCallsChanged
);
} }
public onJoinCall() { public onJoinCall() {
if (!ElementCallOpenTelemetry.instance) return;
// Create the main span that tracks the time we intend to be in the call // Create the main span that tracks the time we intend to be in the call
this.callMembershipSpan = this.callMembershipSpan =
ElementCallOpenTelemetry.instance.tracer.startSpan( ElementCallOpenTelemetry.instance.tracer.startSpan(
@ -98,12 +137,13 @@ export class OTelGroupCallMembership {
this.groupCall.groupCallId this.groupCall.groupCallId
); );
this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId); this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId);
this.callMembershipSpan.setAttribute("matrix.deviceId", this.myDeviceId);
this.callMembershipSpan.setAttribute( this.callMembershipSpan.setAttribute(
"matrix.displayName", "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(), opentelemetry.context.active(),
this.callMembershipSpan this.callMembershipSpan
); );
@ -116,7 +156,7 @@ export class OTelGroupCallMembership {
// and end the span to indicate we've left // and end the span to indicate we've left
this.callMembershipSpan!.end(); this.callMembershipSpan!.end();
this.callMembershipSpan = undefined; this.callMembershipSpan = undefined;
this.callMembershipContext = undefined; this.groupCallContext = undefined;
} }
public onUpdateRoomState(event: MatrixEvent) { public onUpdateRoomState(event: MatrixEvent) {
@ -129,28 +169,111 @@ export class OTelGroupCallMembership {
} }
this.callMembershipSpan?.addEvent( this.callMembershipSpan?.addEvent(
`otel_onRoomStateEvent_${event.getType()}`, `matrix.roomStateEvent_${event.getType()}`,
flattenVoipEvent(event.getContent()) 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; const eventType = event.eventType as string;
if (!eventType.startsWith("m.call")) return; 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") { if (event.type === "toDevice") {
this.callMembershipSpan?.addEvent( callTrackingInfo.span.addEvent(
`matrix.sendToDeviceEvent_${event.eventType}`, `matrix.sendToDeviceEvent_${event.eventType}`,
flattenVoipEvent(event) flattenVoipEvent(event)
); );
} else if (event.type === "sendEvent") { } else if (event.type === "sendEvent") {
this.callMembershipSpan?.addEvent( callTrackingInfo.span.addEvent(
`matrix.sendToRoomEvent_${event.eventType}`, `matrix.sendToRoomEvent_${event.eventType}`,
flattenVoipEvent(event) 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) { public onToggleMicrophoneMuted(newValue: boolean) {
this.callMembershipSpan?.addEvent("matrix.toggleMicMuted", { this.callMembershipSpan?.addEvent("matrix.toggleMicMuted", {
"matrix.microphone.muted": newValue, "matrix.microphone.muted": newValue,
@ -194,7 +317,7 @@ export class OTelGroupCallMembership {
const span = ElementCallOpenTelemetry.instance.tracer.startSpan( const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
"matrix.audioActivity", "matrix.audioActivity",
undefined, undefined,
this.callMembershipContext this.groupCallContext
); );
span.setAttribute("matrix.userId", member.userId); span.setAttribute("matrix.userId", member.userId);
span.setAttribute("matrix.displayName", member.rawDisplayName); span.setAttribute("matrix.displayName", member.rawDisplayName);
@ -210,4 +333,106 @@ export class OTelGroupCallMembership {
if (deviceMap?.size === 0) this.speakingSpans.delete(member); 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 { Config } from "../config/Config";
import { getSetting, settingsBus } from "../settings/useSetting"; import { getSetting, settingsBus } from "../settings/useSetting";
const SERVICE_NAME_BASE = "element-call"; const SERVICE_NAME = "element-call";
let sharedInstance: ElementCallOpenTelemetry; let sharedInstance: ElementCallOpenTelemetry;
@ -48,7 +48,7 @@ export class ElementCallOpenTelemetry {
return sharedInstance; return sharedInstance;
} }
constructor(collectorUrl: string) { constructor(collectorUrl: string | undefined) {
const otlpExporter = new OTLPTraceExporter({ const otlpExporter = new OTLPTraceExporter({
url: collectorUrl, 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. // This is how we can make Jaeger show a reaonsable service in the dropdown on the left.
const providerConfig = { const providerConfig = {
resource: new Resource({ resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: `${SERVICE_NAME_BASE}-unauthenticated`, [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME,
}), }),
}; };
this._provider = new WebTracerProvider(providerConfig); this._provider = new WebTracerProvider(providerConfig);
@ -88,12 +88,16 @@ export class ElementCallOpenTelemetry {
} }
function recheckOTelEnabledStatus(optInAnalayticsEnabled: boolean): void { 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"); logger.info("Starting OpenTelemetry debug reporting");
sharedInstance = new ElementCallOpenTelemetry( sharedInstance = new ElementCallOpenTelemetry(
Config.get().opentelemetry?.collector_url Config.get().opentelemetry?.collector_url
); );
} else if (!optInAnalayticsEnabled && sharedInstance) { } else if (!shouldEnable && sharedInstance) {
logger.info("Stopping OpenTelemetry debug reporting"); logger.info("Stopping OpenTelemetry debug reporting");
sharedInstance = undefined; sharedInstance = undefined;
} }

View file

@ -28,10 +28,20 @@ import ReactJson, { CollapsedFieldProps } from "react-json-view";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event"; 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 { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; 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 styles from "./GroupCallInspector.module.css";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
@ -388,26 +398,49 @@ function useGroupCallState(
function onReceivedVoipEvent(event: MatrixEvent) { function onReceivedVoipEvent(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event }); dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
otelGroupCallMembership?.onReceivedVoipEvent(event);
} }
function onSendVoipEvent(event: VoipEvent) { function onSendVoipEvent(event: VoipEvent, call: MatrixCall) {
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event }); 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) { function onUndecryptableToDevice(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event }); dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
Sentry.captureMessage("Undecryptable to-device Event"); Sentry.captureMessage("Undecryptable to-device Event");
// probably unnecessary if it's now captured via otel?
PosthogAnalytics.instance.eventUndecryptableToDevice.track( PosthogAnalytics.instance.eventUndecryptableToDevice.track(
groupCall.groupCallId groupCall.groupCallId
); );
otelGroupCallMembership.onUndecryptableToDevice(event);
} }
client.on(RoomStateEvent.Events, onUpdateRoomState); client.on(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.on("calls_changed", onCallsChanged);
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent); 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("state", onCallsChanged);
//client.on("hangup", onCallHangup); //client.on("hangup", onCallHangup);
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent); client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
@ -417,8 +450,10 @@ function useGroupCallState(
return () => { return () => {
client.removeListener(RoomStateEvent.Events, onUpdateRoomState); client.removeListener(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.removeListener("calls_changed", onCallsChanged);
groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent); 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("state", onCallsChanged);
//client.removeListener("hangup", onCallHangup); //client.removeListener("hangup", onCallHangup);
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent); client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);

View file

@ -27,6 +27,7 @@ import { useUrlParams } from "../UrlParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler"; import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError"; import { translatedError } from "../TranslatedError";
import { useOptInAnalytics } from "../settings/useSetting";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -46,9 +47,15 @@ export const RoomPage: FC = () => {
const roomIdOrAlias = roomId ?? roomAlias; const roomIdOrAlias = roomId ?? roomAlias;
if (!roomIdOrAlias) throw translatedError("No room specified", t); if (!roomIdOrAlias) throw translatedError("No room specified", t);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
useEffect(() => { useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as // 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 // a URL param, automatically register a passwordless user

View file

@ -22,12 +22,19 @@ import {
GroupCallErrorCode, GroupCallErrorCode,
GroupCallUnknownDeviceError, GroupCallUnknownDeviceError,
GroupCallError, GroupCallError,
GroupCallStatsReportEvent,
GroupCallStatsReport,
} from "matrix-js-sdk/src/webrtc/groupCall"; } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IWidgetApiRequest } from "matrix-widget-api"; import { IWidgetApiRequest } from "matrix-widget-api";
import { MatrixClient } from "matrix-js-sdk"; import { MatrixClient } from "matrix-js-sdk";
import {
ByteSentStatsReport,
ConnectionStatsReport,
SummaryStatsReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { usePageUnload } from "./usePageUnload"; import { usePageUnload } from "./usePageUnload";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
@ -173,6 +180,8 @@ export function useGroupCall(
}); });
if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) { if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) {
if (groupCallOTelMembership) groupCallOTelMembership.dispose();
// If the user disables analytics, this will stay around until they leave the call // If the user disables analytics, this will stay around until they leave the call
// so analytics will be disabled once they leave. // so analytics will be disabled once they leave.
if (ElementCallOpenTelemetry.instance) { if (ElementCallOpenTelemetry.instance) {
@ -200,6 +209,11 @@ export function useGroupCall(
[] []
); );
const leaveCall = useCallback(() => {
groupCallOTelMembership?.onLeaveCall();
groupCall.leave();
}, [groupCall]);
useEffect(() => { useEffect(() => {
// disable the media action keys, otherwise audio elements get paused when // disable the media action keys, otherwise audio elements get paused when
// the user presses media keys or unplugs headphones, etc. // the user presses media keys or unplugs headphones, etc.
@ -214,7 +228,7 @@ export function useGroupCall(
]; ];
for (const mediaAction of mediaActions) { for (const mediaAction of mediaActions) {
navigator.mediaSession.setActionHandler( navigator.mediaSession?.setActionHandler(
mediaAction, mediaAction,
doNothingMediaActionCallback doNothingMediaActionCallback
); );
@ -222,7 +236,7 @@ export function useGroupCall(
return () => { return () => {
for (const mediaAction of mediaActions) { for (const mediaAction of mediaActions) {
navigator.mediaSession.setActionHandler(mediaAction, null); navigator.mediaSession?.setActionHandler(mediaAction, null);
} }
}; };
}, [doNothingMediaActionCallback]); }, [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.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged); groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on( groupCall.on(
@ -346,6 +378,18 @@ export function useGroupCall(
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
groupCall.on(GroupCallEvent.Error, onError); groupCall.on(GroupCallEvent.Error, onError);
groupCall.on(
GroupCallStatsReportEvent.ConnectionStats,
onConnectionStatsReport
);
groupCall.on(
GroupCallStatsReportEvent.ByteSentStats,
onByteSentStatsReport
);
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
updateState({ updateState({
error: null, error: null,
state: groupCall.state, state: groupCall.state,
@ -392,12 +436,24 @@ export function useGroupCall(
onParticipantsChanged onParticipantsChanged
); );
groupCall.removeListener(GroupCallEvent.Error, onError); 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(() => { usePageUnload(() => {
groupCall.leave(); leaveCall();
}); });
const initLocalCallFeed = useCallback( const initLocalCallFeed = useCallback(
@ -416,19 +472,16 @@ export function useGroupCall(
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId); 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) => { groupCall.enter().catch((error) => {
console.error(error); console.error(error);
updateState({ error }); updateState({ error });
}); });
groupCallOTelMembership?.onJoinCall();
}, [groupCall, updateState]); }, [groupCall, updateState]);
const leave = useCallback(() => {
groupCallOTelMembership?.onLeaveCall();
groupCall.leave();
}, [groupCall]);
const toggleLocalVideoMuted = useCallback(() => { const toggleLocalVideoMuted = useCallback(() => {
const toggleToMute = !groupCall.isLocalVideoMuted(); const toggleToMute = !groupCall.isLocalVideoMuted();
groupCall.setLocalVideoMuted(toggleToMute); groupCall.setLocalVideoMuted(toggleToMute);
@ -561,7 +614,7 @@ export function useGroupCall(
error, error,
initLocalCallFeed, initLocalCallFeed,
enter, enter,
leave, leave: leaveCall,
toggleLocalVideoMuted, toggleLocalVideoMuted,
toggleMicrophoneMuted, toggleMicrophoneMuted,
toggleScreensharing, toggleScreensharing,

View file

@ -20,7 +20,7 @@ limitations under the License.
} }
.tabContainer { .tabContainer {
margin: 27px 16px; padding: 27px 20px;
} }
.fieldRowText { .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. Without a defined width activating the developer tab makes the tab container jump to the right.
*/ */
.tabLabel { .tabLabel {
width: 80px; min-width: 80px;
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@ -32,15 +32,14 @@ import {
useSpatialAudio, useSpatialAudio,
useShowInspector, useShowInspector,
useOptInAnalytics, useOptInAnalytics,
canEnableSpatialAudio,
useNewGrid, useNewGrid,
useDeveloperSettingsTab, useDeveloperSettingsTab,
} from "./useSetting"; } from "./useSetting";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake"; import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography"; import { Body, Caption } from "../typography/Typography";
import { optInDescription } from "../analytics/AnalyticsOptInDescription"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@ -71,6 +70,17 @@ export const SettingsModal = (props: Props) => {
const downloadDebugLog = useDownloadDebugLog(); 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 ( return (
<Modal <Modal
title={t("Settings")} title={t("Settings")}
@ -122,16 +132,16 @@ export const SettingsModal = (props: Props) => {
label={t("Spatial audio")} label={t("Spatial audio")}
type="checkbox" type="checkbox"
checked={spatialAudio} checked={spatialAudio}
disabled={!canEnableSpatialAudio()} disabled={setSpatialAudio === null}
description={ description={
canEnableSpatialAudio() setSpatialAudio === null
? t( ? 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.)" "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>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSpatialAudio(event.target.checked) setSpatialAudio!(event.target.checked)
} }
/> />
</FieldRow> </FieldRow>
@ -187,7 +197,7 @@ export const SettingsModal = (props: Props) => {
id="optInAnalytics" id="optInAnalytics"
type="checkbox" type="checkbox"
checked={optInAnalytics} checked={optInAnalytics}
description={optInDescription()} description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked) setOptInAnalytics(event.target.checked)
} }

View file

@ -17,6 +17,11 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { useMemo, useState, useEffect, useCallback } from "react"; 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 // Bus to notify other useSetting consumers when a setting is changed
export const settingsBus = new EventEmitter(); export const settingsBus = new EventEmitter();
@ -24,10 +29,7 @@ const getSettingKey = (name: string): string => {
return `matrix-setting-${name}`; return `matrix-setting-${name}`;
}; };
// Like useState, but reads from and persists the value to localStorage // Like useState, but reads from and persists the value to localStorage
const useSetting = <T>( const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
name: string,
defaultValue: T
): [T, (value: T) => void] => {
const key = useMemo(() => getSettingKey(name), [name]); const key = useMemo(() => getSettingKey(name), [name]);
const [value, setValue] = useState<T>(() => { const [value, setValue] = useState<T>(() => {
@ -65,7 +67,7 @@ export const setSetting = <T>(name: string, newValue: T) => {
settingsBus.emit(name, newValue); settingsBus.emit(name, newValue);
}; };
export const canEnableSpatialAudio = () => { const canEnableSpatialAudio = () => {
const { userAgent } = navigator; const { userAgent } = navigator;
// Spatial audio means routing audio through audio contexts. On Chrome, // Spatial audio means routing audio through audio contexts. On Chrome,
// this bypasses the AEC processor and so breaks echo cancellation. // this bypasses the AEC processor and so breaks echo cancellation.
@ -79,17 +81,27 @@ export const canEnableSpatialAudio = () => {
return userAgent.includes("Firefox"); return userAgent.includes("Firefox");
}; };
export const useSpatialAudio = (): [boolean, (val: boolean) => void] => { export const useSpatialAudio = (): DisableableSetting<boolean> => {
const settingVal = useSetting("spatial-audio", false); const settingVal = useSetting("spatial-audio", false);
if (canEnableSpatialAudio()) return settingVal; if (canEnableSpatialAudio()) return settingVal;
return [false, (_: boolean) => {}]; return [false, null];
}; };
export const useShowInspector = () => useSetting("show-inspector", false); 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 = () => export const useKeyboardShortcuts = () =>
useSetting("keyboard-shortcuts", true); useSetting("keyboard-shortcuts", true);
export const useNewGrid = () => useSetting("new-grid", false); export const useNewGrid = () => useSetting("new-grid", false);
export const useDeveloperSettingsTab = () => export const useDeveloperSettingsTab = () =>
useSetting("developer-settings-tab", false); useSetting("developer-settings-tab", false);

View file

@ -88,7 +88,9 @@ limitations under the License.
.tabContainer { .tabContainer {
width: 100%; width: 100%;
flex-direction: row; flex-direction: row;
margin: 27px 16px; padding: 27px 20px;
box-sizing: border-box;
overflow: hidden;
} }
.tabList { .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" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw== integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3": "@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.5":
version "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.5.tgz#60ede2c43b9d808ba8cf46085a3b347b290d9658" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.6.tgz#c0bdb9ab0d30179b8ef744d1b4010b0ad0ab9c3a"
integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw== 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": "@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" version "3.2.14"
@ -5726,7 +5726,12 @@ content-disposition@0.5.4:
dependencies: dependencies:
safe-buffer "5.2.1" 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" version "1.0.4"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
@ -10413,9 +10418,9 @@ log-symbols@^4.1.0:
is-unicode-supported "^0.1.0" is-unicode-supported "^0.1.0"
loglevel@^1.7.1: loglevel@^1.7.1:
version "1.8.0" version "1.8.1"
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4"
integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA== integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==
long@^2.4.0: long@^2.4.0:
version "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" resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#23837266fca5ee799b51a722f7b8eefb2f5ac140": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#e89467c9fbf98182def2088a947155f30fdc7d1f":
version "23.5.0" version "24.0.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/23837266fca5ee799b51a722f7b8eefb2f5ac140" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e89467c9fbf98182def2088a947155f30fdc7d1f"
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@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" another-json "^0.2.0"
bs58 "^5.0.0" bs58 "^5.0.0"
content-type "^1.0.4" content-type "^1.0.4"
loglevel "^1.7.1" loglevel "^1.7.1"
matrix-events-sdk "0.0.1" matrix-events-sdk "0.0.1"
matrix-widget-api "^1.0.0" matrix-widget-api "^1.3.1"
p-retry "4" p-retry "4"
sdp-transform "^2.14.1" sdp-transform "^2.14.1"
unhomoglyph "^1.0.6" unhomoglyph "^1.0.6"
uuid "9" uuid "9"
matrix-widget-api@^1.0.0: matrix-widget-api@^1.3.1:
version "1.1.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.1.1.tgz#d3fec45033d0cbc14387a38ba92dac4dbb1be962" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.3.1.tgz#e38f404c76bb15c113909505c1c1a5b4d781c2f5"
integrity sha512-gNSgmgSwvOsOcWK9k2+tOhEMYBiIMwX95vMZu0JqY7apkM02xrOzUBuPRProzN8CnbIALH7e3GAhatF6QCNvtA== integrity sha512-+rN6vGvnXm+fn0uq9r2KWSL/aPtehD6ObC50jYmUcEfgo8CUpf9eUurmjbRlwZkWq3XHXFuKQBUCI9UzqWg37Q==
dependencies: dependencies:
"@types/events" "^3.0.0" "@types/events" "^3.0.0"
events "^3.2.0" events "^3.2.0"