Merge branch 'main' into audio-observability
This commit is contained in:
@@ -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) {
|
||||
|
||||
14
src/analytics/AnalyticsNotice.tsx
Normal file
14
src/analytics/AnalyticsNotice.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
.
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -37,3 +37,7 @@ limitations under the License.
|
||||
.recentCallsTitle {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--secondary-content);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -45,3 +45,7 @@ limitations under the License.
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--secondary-content);
|
||||
}
|
||||
|
||||
@@ -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
119
src/otel/OTelCall.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
97
src/otel/ObjectFlattener.ts
Normal file
97
src/otel/ObjectFlattener.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user