Analytics configuration is the responsibility of the host application when running in widget mode (#3089)

* Support for analytics configuration via URL parameters in widget mode

Adds:

- posthogApiHost
- posthogApiKey
- rageshakeSubmitUrl
- sentryDsn
- sentryEnvironment

Deprecate analyticsId and use posthogUserId instead

* Partial test coverage

* Simplify tests

* More tests

* Lint

* Split embedded only parameters into own section for clarity

* Update docs/url-params.md

* Update docs/url-params.md

* Update vite.config.js
This commit is contained in:
Hugh Nimmo-Smith
2025-03-21 10:15:20 +00:00
committed by GitHub
parent 7ca70cf4ab
commit 6043b3949b
15 changed files with 673 additions and 100 deletions

View File

@@ -105,7 +105,15 @@ export interface UrlParams {
/**
* The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
*/
analyticsID: string | null;
posthogUserId: string | null;
/**
* The Posthog API host. This is only used in the embedded package of Element Call.
*/
posthogApiHost: string | null;
/**
* The Posthog API key. This is only used in the embedded package of Element Call.
*/
posthogApiKey: string | null;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
@@ -155,6 +163,20 @@ export interface UrlParams {
* If it was a Join Call button, it would be `join_existing`.
*/
intent: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
}
// This is here as a stopgap, but what would be far nicer is a function that
@@ -257,7 +279,6 @@ export const getUrlParams = (
lang: parser.getParam("lang"),
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
analyticsID: parser.getParam("analyticsID"),
allowIceFallback: parser.getFlagParam("allowIceFallback"),
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
skipLobby: parser.getFlagParam(
@@ -271,6 +292,13 @@ export const getUrlParams = (
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
intent,
posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId:
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
};
};

View File

@@ -0,0 +1,91 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
describe("PosthogAnalytics", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value or URL params", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("ignores config value and does not create instance", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("uses URL params if both set", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("ignores URL params and does not create instance", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("creates instance with config value", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
});

View File

@@ -71,11 +71,6 @@ interface PlatformProperties {
cryptoVersion?: string;
}
interface PosthogSettings {
project_api_key?: string;
api_host?: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
@@ -113,24 +108,27 @@ export class PosthogAnalytics {
return this.internalInstance;
}
public static resetInstance(): void {
// Reset the singleton instance
this.internalInstance = null;
}
private constructor(private readonly posthog: PostHog) {
const posthogConfig: PosthogSettings = {
project_api_key: Config.get().posthog?.api_key,
api_host: Config.get().posthog?.api_host,
};
let apiKey: string | undefined;
let apiHost: string | undefined;
if (import.meta.env.VITE_PACKAGE === "embedded") {
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
apiKey = getUrlParams().posthogApiKey ?? undefined;
apiHost = getUrlParams().posthogApiHost ?? undefined;
} else if (import.meta.env.VITE_PACKAGE === "full") {
// in full package it is the server responsible for the analytics
apiKey = Config.get().posthog?.api_key;
apiHost = Config.get().posthog?.api_host;
}
if (posthogConfig.project_api_key && posthogConfig.api_host) {
if (
PosthogAnalytics.getPlatformProperties().matrixBackend === "embedded"
) {
const { analyticsID } = getUrlParams();
// if the embedding platform (element web) already got approval to communicating with posthog
// element call can also send events to posthog
optInAnalytics.setValue(Boolean(analyticsID));
}
this.posthog.init(posthogConfig.project_api_key, {
api_host: posthogConfig.api_host,
if (apiKey && apiHost) {
this.posthog.init(apiKey, {
api_host: apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
@@ -274,7 +272,7 @@ export class PosthogAnalytics {
const client: MatrixClient = window.matrixclient;
let accountAnalyticsId: string | null;
if (widget) {
accountAnalyticsId = getUrlParams().analyticsID;
accountAnalyticsId = getUrlParams().posthogUserId;
} else {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
export interface ConfigOptions {
/**
* The Posthog endpoint to which analytics data will be sent.
* This is only used in the full package of Element Call.
*/
posthog?: {
api_key: string;
@@ -15,6 +16,7 @@ export interface ConfigOptions {
};
/**
* The Sentry endpoint to which crash data will be sent.
* This is only used in the full package of Element Call.
*/
sentry?: {
DSN: string;
@@ -22,6 +24,7 @@ export interface ConfigOptions {
};
/**
* The rageshake server to which feedback and debug logs will be sent.
* This is only used in the full package of Element Call.
*/
rageshake?: {
submit_url: string;
@@ -29,7 +32,7 @@ export interface ConfigOptions {
/**
* Sets the URL to send opentelemetry data to. If unset, opentelemetry will
* be disabled.
* be disabled. This is only used in the full package of Element Call.
*/
opentelemetry?: {
collector_url: string;

View File

@@ -5,24 +5,158 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { Initializer } from "../src/initializer";
import { mockConfig } from "./utils/test";
test("initBeforeReact sets font family from URL param", async () => {
window.location.hash = "#?font=DejaVu Sans";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family",
),
).toBe('"DejaVu Sans"');
});
test("initBeforeReact sets font scale from URL param", async () => {
window.location.hash = "#?fontScale=1.2";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue("--font-scale"),
).toBe("1.2");
const sentryInitSpy = vi.fn();
// Place the mock after the spy is defined
vi.mock("@sentry/react", () => ({
init: sentryInitSpy,
reactRouterV7BrowserTracingIntegration: vi.fn(),
}));
describe("Initializer", async () => {
// we import here to make sure that Sentry is mocked first
const { Initializer } = await import("./initializer.tsx");
describe("initBeforeReact()", () => {
it("sets font family from URL param", async () => {
window.location.hash = "#?font=DejaVu Sans";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family",
),
).toBe('"DejaVu Sans"');
});
it("sets font scale from URL param", async () => {
window.location.hash = "#?fontScale=1.2";
await Initializer.initBeforeReact();
expect(
getComputedStyle(document.documentElement).getPropertyValue(
"--font-scale",
),
).toBe("1.2");
});
});
describe("init()", () => {
describe("sentry setup", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
Initializer.reset();
});
afterEach(() => {
sentryInitSpy.mockClear();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not call Sentry.init() without config value", async () => {
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("ignores config value and does not create instance", async () => {
mockConfig({
sentry: {
DSN: "https://config.example.com.localhost",
environment: "config",
},
});
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("uses sentryDsn param if set", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}`;
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: undefined,
}),
);
});
it("uses sentryDsn and sentryEnvironment params if set", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
}),
);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
Initializer.reset();
});
afterEach(() => {
sentryInitSpy.mockClear();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value or URL param", async () => {
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("ignores URL params and does not create instance", async () => {
window.location.hash = `#?sentryDsn=${encodeURIComponent("https://dsn.example.com.localhost")}&sentryEnvironment=fooEnvironment`;
await Initializer.init();
expect(sentryInitSpy).not.toHaveBeenCalled();
});
it("creates instance with config value", async () => {
mockConfig({
sentry: {
DSN: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
},
});
await Initializer.init();
expect(sentryInitSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://dsn.example.com.localhost",
environment: "fooEnvironment",
}),
);
});
});
});
});
});

View File

@@ -109,11 +109,11 @@ class DependencyLoadStates {
}
export class Initializer {
private static internalInstance: Initializer;
private static internalInstance: Initializer | undefined;
private isInitialized = false;
public static isInitialized(): boolean {
return Initializer.internalInstance?.isInitialized;
return !!Initializer.internalInstance?.isInitialized;
}
public static async initBeforeReact(): Promise<void> {
@@ -193,11 +193,19 @@ export class Initializer {
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
// initStep calls itself recursively until everything is initialized in the correct order.
// Then the promise gets resolved.
Initializer.internalInstance.initStep(resolve);
Initializer.internalInstance?.initStep(resolve);
});
return Initializer.internalInstance.initPromise;
}
/**
* Resets the initializer. This is used in tests to ensure that the initializer
* is re-initialized for each test.
*/
public static reset(): void {
Initializer.internalInstance = undefined;
}
private loadStates = new DependencyLoadStates();
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
@@ -220,10 +228,22 @@ export class Initializer {
this.loadStates.sentry === LoadState.None &&
this.loadStates.config === LoadState.Loaded
) {
if (Config.get().sentry?.DSN && Config.get().sentry?.environment) {
let dsn: string | undefined;
let environment: string | undefined;
if (import.meta.env.VITE_PACKAGE === "embedded") {
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
dsn = getUrlParams().sentryDsn ?? undefined;
environment = getUrlParams().sentryEnvironment ?? undefined;
}
if (import.meta.env.VITE_PACKAGE === "full") {
// in full package it is the server responsible for the analytics
dsn = Config.get().sentry?.DSN;
environment = Config.get().sentry?.environment;
}
if (dsn) {
Sentry.init({
dsn: Config.get().sentry?.DSN,
environment: Config.get().sentry?.environment,
dsn,
environment,
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect: React.useEffect,

79
src/otel/otel.test.ts Normal file
View File

@@ -0,0 +1,79 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { ElementCallOpenTelemetry } from "./otel";
import { mockConfig } from "../utils/test";
describe("ElementCallOpenTelemetry", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
beforeEach(() => {
mockConfig({});
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
it("ignores config value and does not create instance", () => {
mockConfig({
opentelemetry: {
collector_url: "https://collector.example.com.localhost",
},
});
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({});
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(false);
});
it("creates instance with config value", () => {
mockConfig({
opentelemetry: {
collector_url: "https://collector.example.com.localhost",
},
});
ElementCallOpenTelemetry.globalInit();
expect(ElementCallOpenTelemetry.instance?.isOtlpEnabled).toBe(true);
});
});
});

View File

@@ -16,6 +16,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
import { Config } from "../config/Config";
import { RageshakeSpanProcessor } from "../analytics/RageshakeSpanProcessor";
import { getRageshakeSubmitUrl } from "../settings/submit-rageshake";
const SERVICE_NAME = "element-call";
@@ -28,20 +29,24 @@ export class ElementCallOpenTelemetry {
public readonly rageshakeProcessor?: RageshakeSpanProcessor;
public static globalInit(): void {
const config = Config.get();
// this is only supported in the full package as the is currently no support for passing in the collector URL from the widget host
const collectorUrl =
import.meta.env.VITE_PACKAGE === "full"
? Config.get().opentelemetry?.collector_url
: undefined;
// we always enable opentelemetry in general. We only enable the OTLP
// collector if a URL is defined (and in future if another setting is defined)
// Posthog reporting is enabled or disabled
// within the posthog code.
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);
const shouldEnableOtlp = Boolean(collectorUrl);
if (!sharedInstance || sharedInstance.isOtlpEnabled !== shouldEnableOtlp) {
logger.info("(Re)starting OpenTelemetry debug reporting");
sharedInstance?.dispose();
sharedInstance = new ElementCallOpenTelemetry(
config.opentelemetry?.collector_url,
config.rageshake?.submit_url,
collectorUrl,
getRageshakeSubmitUrl(),
);
}
}

View File

@@ -23,7 +23,8 @@ interface Props {
export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const { submitRageshake, sending, sent, error, available } =
useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
@@ -66,20 +67,27 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
</Text>
);
return (
<div>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
// in the embedded package the widget host is responsible for analytics consent
const analyticsConsentBlock =
import.meta.env.VITE_PACKAGE === "embedded" ? null : (
<>
<h4>{t("common.analytics")}</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</>
);
const feedbackBlock = available ? (
<>
<h4>{t("settings.feedback_tab_h4")}</h4>
<Text>{t("settings.feedback_tab_body")}</Text>
<form onSubmit={onSubmitFeedback}>
@@ -113,6 +121,13 @@ export const FeedbackSettingsTab: FC<Props> = ({ roomId }) => {
{sent && <Text>{t("settings.feedback_tab_thank_you")}</Text>}
</FieldRow>
</form>
</>
) : null;
return (
<div>
{analyticsConsentBlock}
{feedbackBlock}
</div>
);
};

View File

@@ -30,6 +30,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { isRageshakeAvailable } from "./submit-rageshake";
type SettingsTab =
| "audio"
@@ -146,7 +147,12 @@ export const SettingsModal: FC<Props> = ({
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
tabs.push(preferencesTab, feedbackTab);
tabs.push(preferencesTab);
if (isRageshakeAvailable() || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox
// even if rageshake is not available
tabs.push(feedbackTab);
}
if (showDeveloperSettingsTab) tabs.push(developerTab);
return (

View File

@@ -0,0 +1,153 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
afterEach,
vi,
type Mock,
beforeEach,
} from "vitest";
import {
getRageshakeSubmitUrl,
isRageshakeAvailable,
} from "./submit-rageshake";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
describe("isRageshakeAvailable", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns false with no rageshakeSubmitUrl URL param", () => {
expect(isRageshakeAvailable()).toBe(false);
});
it("ignores config value and returns false with no rageshakeSubmitUrl URL param", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(isRageshakeAvailable()).toBe(false);
});
it("returns true with rageshakeSubmitUrl URL param", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(isRageshakeAvailable()).toBe(true);
});
});
describe("full package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns false with no config value", () => {
expect(isRageshakeAvailable()).toBe(false);
});
it("ignores rageshakeSubmitUrl URL param and returns false with no config value", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(isRageshakeAvailable()).toBe(false);
});
it("returns true with config value", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(isRageshakeAvailable()).toBe(true);
});
});
});
describe("getRageshakeSubmitUrl", () => {
beforeEach(() => {
(getUrlParams as Mock).mockReturnValue({});
mockConfig({});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("embedded package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("returns undefined no rageshakeSubmitUrl URL param", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns rageshakeSubmitUrl URL param when set", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBe("https://url.example.com.localhost");
});
it("ignores config param and returns undefined", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
});
describe("full package", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("returns undefined with no config value", () => {
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("ignores rageshakeSubmitUrl URL param and returns undefined", () => {
(getUrlParams as Mock).mockReturnValue({
rageshakeSubmitUrl: "https://url.example.com.localhost",
});
expect(getRageshakeSubmitUrl()).toBeUndefined();
});
it("returns config value when set", () => {
mockConfig({
rageshake: {
submit_url: "https://config.example.com.localhost",
},
});
expect(getRageshakeSubmitUrl()).toBe(
"https://config.example.com.localhost",
);
});
});
});

View File

@@ -19,6 +19,7 @@ import { useClient } from "../ClientContext";
import { Config } from "../config/Config";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
import { getUrlParams } from "../UrlParams";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
@@ -116,11 +117,30 @@ interface RageShakeSubmitOptions {
label?: string;
}
export function getRageshakeSubmitUrl(): string | undefined {
if (import.meta.env.VITE_PACKAGE === "full") {
// in full package we always use the one configured on the server
return Config.get().rageshake?.submit_url;
}
if (import.meta.env.VITE_PACKAGE === "embedded") {
// in embedded package we always use the one provided by the widget host
return getUrlParams().rageshakeSubmitUrl ?? undefined;
}
return undefined;
}
export function isRageshakeAvailable(): boolean {
return !!getRageshakeSubmitUrl();
}
export function useSubmitRageshake(): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error?: Error;
available: boolean;
} {
const { client } = useClient();
@@ -138,7 +158,7 @@ export function useSubmitRageshake(): {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async (opts) => {
if (!Config.get().rageshake?.submit_url) {
if (!getRageshakeSubmitUrl()) {
throw new Error("No rageshake URL is configured");
}
@@ -297,6 +317,7 @@ export function useSubmitRageshake(): {
sending,
sent,
error,
available: isRageshakeAvailable(),
};
}

View File

@@ -264,6 +264,8 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
...DEFAULT_CONFIG,
...config,
});
// simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0);
}
export class MockRTCSession extends TypedEventEmitter<