From 2e9ad14cb09ff2a03c54f4b6b2601267905cc728 Mon Sep 17 00:00:00 2001 From: Ashish Kolhe <35160958+oasisk@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:58:24 +0000 Subject: [PATCH 1/7] fix(config): serve the RUM config only to signed-in users (#15946) --- src/api/http/src/handler/http/router/mod.rs | 5 +- src/api/management/src/request/status/mod.rs | 2 - tests/integration_test.rs | 14 +- web/src/layouts/MainLayout.rum.spec.ts | 20 ++ web/src/layouts/MainLayout.vue | 10 +- web/src/main.spec.ts | 91 +------ web/src/main.ts | 148 ---------- web/src/utils/rum/ensureBrowserRum.spec.ts | 107 ++++++++ web/src/utils/rum/ensureBrowserRum.ts | 41 +++ web/src/utils/rum/initBrowserRum.spec.ts | 269 +++++++++++++++++++ web/src/utils/rum/initBrowserRum.ts | 184 +++++++++++++ web/src/views/AwsMarketplaceSetup.spec.ts | 8 + web/src/views/AwsMarketplaceSetup.vue | 3 + web/src/views/AzureMarketplaceSetup.spec.ts | 8 + web/src/views/AzureMarketplaceSetup.vue | 3 + web/src/views/SlackConnectCallback.spec.ts | 7 + web/src/views/SlackConnectCallback.vue | 2 + web/src/views/SlackLink.spec.ts | 7 + web/src/views/SlackLink.vue | 6 +- web/src/views/SlackLinkCallback.spec.ts | 7 + web/src/views/SlackLinkCallback.vue | 2 + 21 files changed, 706 insertions(+), 238 deletions(-) create mode 100644 web/src/utils/rum/ensureBrowserRum.spec.ts create mode 100644 web/src/utils/rum/ensureBrowserRum.ts create mode 100644 web/src/utils/rum/initBrowserRum.spec.ts create mode 100644 web/src/utils/rum/initBrowserRum.ts diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index 67abd19f6c..0603df5537 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -3798,7 +3798,6 @@ mod tests { "custom_logo_text", "editor_suggest_mode", "native_login_enabled", - "rum", "sso_enabled", "telemetry_enabled", ], @@ -3877,6 +3876,10 @@ mod tests { payload.get("version").is_some(), "authenticated config carries the version" ); + assert!( + payload.get("rum").is_some_and(Value::is_object), + "the RUM SDK settings are served only to signed-in callers" + ); assert!( payload.get("instance").is_none(), "the instance id is a fingerprinting handle no UI consumer reads; it must not be served" diff --git a/src/api/management/src/request/status/mod.rs b/src/api/management/src/request/status/mod.rs index 71443a7a39..c96ae67ab5 100644 --- a/src/api/management/src/request/status/mod.rs +++ b/src/api/management/src/request/status/mod.rs @@ -319,7 +319,6 @@ struct ConfigBootstrapResponse { custom_hide_self_logo: bool, /// The editors need it before the authenticated config lands, or `off` arrives too late. editor_suggest_mode: String, - rum: Rum, } #[derive(Serialize, serde::Deserialize)] @@ -462,7 +461,6 @@ pub async fn zo_config_bootstrap() -> impl IntoResponse { custom_logo_dark_img, custom_hide_self_logo, editor_suggest_mode: cfg.limit.editor_suggest_mode.clone(), - rum: Rum::from_cfg(cfg.clone()), }) } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 99af3c11d3..cce652b936 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -6798,9 +6798,19 @@ mod tests { async fn e2e_config() { let auth = setup(); let app = init_test_router(); - let headers = auth_headers(auth); - let (status, _body) = make_request(&app, Method::GET, "/config", Some(headers), None).await; + let (status, body) = make_request(&app, Method::GET, "/config", None, None).await; assert!(status.is_success()); + let bootstrap: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(bootstrap.get("rum").is_none(), "{bootstrap}"); + assert!(bootstrap.get("commit_hash").is_some(), "{bootstrap}"); + + let headers = auth_headers(auth); + let (status, body) = + make_request(&app, Method::GET, "/api/e2e/config", Some(headers), None).await; + assert!(status.is_success()); + let full: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(full["rum"]["version"], config::VERSION, "{full}"); + assert!(full["rum"].get("client_token").is_some(), "{full}"); } // Helper function to create pipeline via API diff --git a/web/src/layouts/MainLayout.rum.spec.ts b/web/src/layouts/MainLayout.rum.spec.ts index 4e12b0d6d8..151716aa02 100644 --- a/web/src/layouts/MainLayout.rum.spec.ts +++ b/web/src/layouts/MainLayout.rum.spec.ts @@ -22,6 +22,8 @@ import router from "@/test/unit/helpers/router"; import config from "@/aws-exports"; import { openobserveRum } from "@openobserve/browser-rum"; import { useLocalOrganization } from "@/utils/zincutils"; +import configService from "@/services/config"; +import { initBrowserRum } from "@/utils/rum/initBrowserRum"; vi.mock("@/services/config", () => ({ default: { get_config_full: vi.fn(() => new Promise(() => {})) }, @@ -32,6 +34,8 @@ vi.mock("@/utils/zincutils", async (importOriginal) => ({ invalidateLoginData: vi.fn(), })); +vi.mock("@/utils/rum/initBrowserRum", () => ({ initBrowserRum: vi.fn() })); + vi.mock("@openobserve/browser-rum", () => ({ openobserveRum: { setUser: vi.fn(), @@ -290,6 +294,22 @@ describe("MainLayout — RUM identity and account", () => { }); }); + it("starts RUM from the authenticated full config", async () => { + const rum = { enabled: true, client_token: "token", application_id: "app", site: "rum" }; + vi.mocked(configService.get_config_full).mockResolvedValueOnce({ + data: { version: "v1", rum }, + } as any); + store.state.zoConfig = { ...originalZoConfig, version: "" }; + wrapper = mountMainLayout(); + await flushPromises(); + + store.state.selectedOrganization = { label: "Rum", identifier: "rum-start" }; + await flushPromises(); + + expect(configService.get_config_full).toHaveBeenCalledWith("rum-start"); + expect(initBrowserRum).toHaveBeenCalledWith(rum, store.state.API_ENDPOINT); + }); + it("does not set the RUM account when RUM is disabled", async () => { store.state.zoConfig = { ...originalZoConfig, version: "v1", rum: { enabled: false } }; diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index d5909f90a9..d818e7422d 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -316,6 +316,7 @@ import { type OrgStreamList, } from "@/composables/firstEvent/useFirstDataNotice"; import { openobserveRum } from "@openobserve/browser-rum"; +import { initBrowserRum } from "@/utils/rum/initBrowserRum"; import useSearchWebSocket from "@/composables/useSearchWebSocket"; import O2AIChat from "@/components/O2AIChat.vue"; import PaidOverageConsentDialog from "@/components/PaidOverageConsentDialog.vue"; @@ -789,7 +790,7 @@ export default defineComponent({ }, ); - // main.ts mounts before the bootstrap config resolves, so rum.enabled can arrive after the org. + // rum.enabled arrives only with the full config, which is fetched after the org is known. watch( [ () => store.state.selectedOrganization?.identifier, @@ -1486,6 +1487,8 @@ export default defineComponent({ linksList.value = layoutMixin.leftNavigationLinks(linksList, t); } + // Before setConfig, so watchers that react to rum.enabled find the SDK running. + initBrowserRum(res.data.rum, store.state.API_ENDPOINT); store.dispatch("setConfig", res.data); fullConfigOrg = orgIdentifier; await nextTick(); @@ -1512,11 +1515,6 @@ export default defineComponent({ } // Fail open: reveal the base menu even if the config never resolves. menuReady.value = true; - // Session replay must not be lost to a failed config fetch — the rum - // settings already arrived with the unauthenticated bootstrap. - if (store.state.zoConfig?.rum?.enabled) { - setRumUser(); - } // A transient failure (expired session being refreshed, rolling // deploy) would otherwise strand the session on the bootstrap subset. if (attempt < FULL_CONFIG_RETRY_DELAYS_MS.length) { diff --git a/web/src/main.spec.ts b/web/src/main.spec.ts index c07932009e..cc8f29bfee 100644 --- a/web/src/main.spec.ts +++ b/web/src/main.spec.ts @@ -5,8 +5,7 @@ const routerOnError = vi.hoisted(() => vi.fn()); const isStaleChunkError = vi.hoisted(() => vi.fn()); const toast = vi.hoisted(() => vi.fn()); const logsInit = vi.hoisted(() => vi.fn()); -const dispatch = vi.hoisted(() => vi.fn()); -const route = vi.hoisted(() => ({ name: "logs" })); +const setInitialVersion = vi.hoisted(() => vi.fn()); vi.mock("@/services/config.queries", () => ({ configQuery: () => ({}) })); vi.mock("vue", async (importOriginal) => { @@ -16,16 +15,10 @@ vi.mock("vue", async (importOriginal) => { }); vi.mock("@tanstack/vue-query", () => ({ VueQueryPlugin: {} })); vi.mock("./stores", () => ({ - default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch }, + default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch: vi.fn() }, })); vi.mock("./App.vue", () => ({ default: {} })); -vi.mock("./router", () => ({ - default: () => ({ - onError: routerOnError, - resolve: () => ({ name: route.name }), - options: { history: { location: "/" } }, - }), -})); +vi.mock("./router", () => ({ default: () => ({ onError: routerOnError }) })); vi.mock("./locales", () => ({ default: { global: { t: (key: string) => key } }, applyDocumentLocale: vi.fn(), @@ -41,7 +34,7 @@ vi.mock("./composables/contextProviders", () => ({ createDefaultContextProvider: vi.fn(), })); vi.mock("./utils/buildVersionChecker", () => ({ - buildVersionChecker: { setInitialVersion: vi.fn(), isStaleChunkError }, + buildVersionChecker: { setInitialVersion, isStaleChunkError }, isChunkLoadError: (e: { message?: string } | undefined) => /dynamically imported module|Unable to preload CSS/.test(e?.message ?? ""), })); @@ -49,6 +42,8 @@ vi.mock("./composables/query/queryClient", () => ({ queryClient: { fetchQuery: () => Promise.resolve({ + commit_hash: "abc123", + // A stale server may still send rum in the bootstrap; the SDK must wait for login anyway. rum: { enabled: true, client_token: "t", @@ -66,63 +61,18 @@ vi.mock("./composables/query/queryClient", () => ({ vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast })); vi.mock("@/utils/themeManager", () => ({ bootstrapTheme: vi.fn() })); -const EXT = "chrome-extension://abcdefghijklmnop"; - // main.ts boots the whole app on import, which can outlast the default hook timeout on a busy runner. beforeAll(async () => { await import("./main"); - await vi.waitFor(() => expect(rumInit).toHaveBeenCalled(), { timeout: 20000 }); + await vi.waitFor(() => expect(setInitialVersion).toHaveBeenCalledWith("abc123"), { + timeout: 20000, + }); }, 60000); -const rumBeforeSend = (): ((event: Record) => boolean) => - rumInit.mock.calls[0][0].beforeSend; - -const errorEvent = (stack: string) => ({ - type: "error", - view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, - error: { message: "boom", stack }, -}); - -describe("main.ts RUM version", () => { - it("sends the server's build version to both the RUM and logs SDKs", () => { - expect(rumInit.mock.calls[0][0].version).toBe("v0.70.0"); - expect(logsInit.mock.calls[0][0].version).toBe("v0.70.0"); - }); -}); - -describe("main.ts RUM beforeSend", () => { - it("drops an error whose every frame is a Chrome extension", async () => { - const beforeSend = rumBeforeSend(); - const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`; - expect(beforeSend(errorEvent(stack))).toBe(false); - }); - - it("sends an error with at least one app frame", async () => { - const beforeSend = rumBeforeSend(); - const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at setup (https://cloud.openobserve.ai/web/assets/index.js:3:4)`; - expect(beforeSend(errorEvent(stack))).toBe(true); - }); - - it("still scrubs sign-in tokens from the view URL of an event it sends", async () => { - const beforeSend = rumBeforeSend(); - const event = { - type: "view", - view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" }, - }; - expect(beforeSend(event)).toBe(true); - expect(event.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted"); - }); - - it("drops a JWT from a click's target name", async () => { - const beforeSend = rumBeforeSend(); - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln"; - const event = { - type: "action", - view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, - action: { target: { name: `Copy ${jwt}` } }, - }; - expect(beforeSend(event)).toBe(true); - expect(event.action.target.name).toBe("Copy "); +describe("main.ts RUM", () => { + it("does not start the RUM or logs SDK from the unauthenticated bootstrap config", () => { + expect(rumInit).not.toHaveBeenCalled(); + expect(logsInit).not.toHaveBeenCalled(); }); }); @@ -226,18 +176,3 @@ describe("main.ts chunk load errors", () => { 60000, ); }); - -describe("main.ts RUM on public dashboards", () => { - it("starts neither the RUM nor the logs SDK for an anonymous public-link viewer", async () => { - route.name = "publicDashboard"; - rumInit.mockClear(); - logsInit.mockClear(); - dispatch.mockClear(); - vi.resetModules(); - await import("./main"); - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledWith("setConfig", expect.anything())); - expect(rumInit).not.toHaveBeenCalled(); - expect(logsInit).not.toHaveBeenCalled(); - route.name = "logs"; - }, 60000); -}); diff --git a/web/src/main.ts b/web/src/main.ts index 97a32cb58c..a8325fa490 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -28,16 +28,10 @@ import "./styles/rtl.css"; import "./assets/styles/log-highlighting.css"; import config from "./aws-exports"; -import { openobserveRum } from "@openobserve/browser-rum"; -import { openobserveLogs } from "@openobserve/browser-logs"; import { useReo } from "./services/reodotdev_analytics"; import { contextRegistry, createDefaultContextProvider } from "./composables/contextProviders"; import { buildVersionChecker, isChunkLoadError } from "./utils/buildVersionChecker"; import { queryClient, setMutationNotifier } from "./composables/query/queryClient"; -import { shouldPropagateTracing } from "./utils/rum/tracingOrigin"; -import { isIgnoredNoise } from "./utils/rum/ignoredNoisePatterns"; -import { scrubAuthParams } from "./utils/rum/scrubAuthParams"; -import { isExtensionOnlyError } from "./utils/rum/extensionFrames"; import { toast } from "@/lib/feedback/Toast/useToast"; import { bootstrapTheme } from "@/utils/themeManager"; import { raw } from "@/types/i18n"; @@ -80,28 +74,9 @@ interface ConfigResponse { telemetry_enabled?: boolean; build_type?: string; commit_hash?: string; - rum?: { - enabled: boolean; - client_token: string; - application_id: string; - site: string; - service: string; - env: string; - version?: string; - organization_identifier: string; - insecure_http?: boolean; - api_version?: string; - }; }; } -// Only the three dot-separated parts make a token, so a bare eyJ inside a word (surveyJsonBtn) is kept. -const JWT_PATTERN = /eyJ[\w-]*\.[\w-]+\.[\w-]*/g; - -// Anonymous public-link viewers are not app users, so their sessions stay out of RUM. -const isPublicDashboard = - router.resolve(router.options.history.location).name === "publicDashboard"; - const getConfig = async () => { // Seeds the shared bootstrap `/config` query — Login and the version checker // read the same cached entry instead of each issuing their own request. The @@ -122,129 +97,6 @@ const getConfig = async () => { if (res.data.commit_hash) { buildVersionChecker.setInitialVersion(res.data.commit_hash); } - if (res.data.rum?.enabled && !isPublicDashboard) { - const options = { - clientToken: res.data.rum.client_token, - applicationId: res.data.rum.application_id, - site: res.data.rum.site, - service: res.data.rum.service, - env: res.data.rum.env, - version: res.data.rum.version || "0.0.1", - organizationIdentifier: res.data.rum.organization_identifier, - insecureHTTP: res.data.rum.insecure_http || false, - apiVersion: res.data.rum.api_version || "v1", - }; - - openobserveRum.init({ - applicationId: options.applicationId, // required, any string identifying your application - clientToken: options.clientToken, - site: options.site, - organizationIdentifier: options.organizationIdentifier, - service: options.service, - env: options.env, - version: options.version, - trackResources: true, - trackLongTasks: true, - trackUserInteractions: true, - actionNameAttribute: "data-test", - apiVersion: options.apiVersion, - insecureHTTP: options.insecureHTTP, - defaultPrivacyLevel: "allow", - // Session renewal drops the forced replay start, so sampling must keep every renewed session recording. - sessionReplaySampleRate: 100, - startSessionReplayRecordingManually: true, - // Same-origin only: cross-origin (dev against a remote cluster) the - // injected headers fail the CORS preflight and kill every API call. - // See shouldPropagateTracing. - allowedTracingUrls: shouldPropagateTracing(store.state.API_ENDPOINT, window.location.origin) - ? [ - { - match: store.state.API_ENDPOINT + "/api", - propagatorTypes: ["openobserve", "tracecontext"], - }, - ] - : [], - beforeSend: (event) => { - event.view.url = scrubAuthParams(event.view.url) ?? event.view.url; - event.view.referrer = scrubAuthParams(event.view.referrer); - if (event.type === "resource") { - event.resource.url = scrubAuthParams(event.resource.url) ?? event.resource.url; - } - if (event.type === "error") { - event.error.message = scrubAuthParams(event.error.message) ?? event.error.message; - if (event.error.resource) { - event.error.resource.url = - scrubAuthParams(event.error.resource.url) ?? event.error.resource.url; - } - } - if (event.type === "error" && isExtensionOnlyError(event.error?.stack)) return false; - if (event.type === "action" && event.action.target?.name) { - event.action.target.name = event.action.target.name.replace(JWT_PATTERN, ""); - } - // Filter out specific errors before sending to RUM - if (event.type === "error") { - const errorMessage = event.error?.message || ""; - const errorStack = event.error?.stack || ""; - - // List of error patterns to ignore - const ignoredErrorPatterns = [ - /ResizeObserver loop/i, - /reo.dev/i, - /Cannot set properties of null \(setting 'innerHTML'\)/, - ]; - - // Check if error matches any ignored pattern - const shouldIgnore = ignoredErrorPatterns.some( - (pattern) => pattern.test(errorMessage) || pattern.test(errorStack), - ); - - if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) { - return false; // Don't send this error - } - } - - // Allow all other events to be sent - return true; - }, - }); - - openobserveLogs.init({ - clientToken: options.clientToken, - site: options.site, - organizationIdentifier: options.organizationIdentifier, - service: options.service, - env: options.env, - version: options.version, - forwardErrorsToLogs: true, - insecureHTTP: options.insecureHTTP, - apiVersion: options.apiVersion, - beforeSend: (log) => { - if (log.view?.url) log.view.url = scrubAuthParams(log.view.url) ?? log.view.url; - if (log.view?.referrer) log.view.referrer = scrubAuthParams(log.view.referrer); - if (log.http?.url) log.http.url = scrubAuthParams(log.http.url) ?? log.http.url; - log.message = scrubAuthParams(log.message) ?? log.message; - // Filter out specific logs before sending - const logMessage = log.message || ""; - - // List of log patterns to ignore - const ignoredLogPatterns = [/ResizeObserver loop/i]; - - // Check if log matches any ignored pattern - const shouldIgnore = ignoredLogPatterns.some((pattern) => pattern.test(logMessage)); - - if (shouldIgnore || isIgnoredNoise(logMessage)) { - return false; // Don't send this log - } - - // Allow all other logs to be sent - return true; - }, - }); - - // Don't start session replay automatically - it will be started after login - // to avoid capturing login page with "Unknown" user - // Session replay will be started in Login.vue and MainLayout.vue after user is authenticated - } }); }; diff --git a/web/src/utils/rum/ensureBrowserRum.spec.ts b/web/src/utils/rum/ensureBrowserRum.spec.ts new file mode 100644 index 0000000000..3fa31ebce9 --- /dev/null +++ b/web/src/utils/rum/ensureBrowserRum.spec.ts @@ -0,0 +1,107 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rumInit = vi.hoisted(() => vi.fn()); +const rumSdk = vi.hoisted(() => ({ + setUser: vi.fn(), + startSessionReplayRecording: vi.fn(), +})); +const logsInit = vi.hoisted(() => vi.fn()); +const getConfigFull = vi.hoisted(() => vi.fn()); + +vi.mock("@openobserve/browser-rum", () => ({ openobserveRum: { init: rumInit, ...rumSdk } })); +vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } })); +vi.mock("@/services/config", () => ({ default: { get_config_full: getConfigFull } })); + +import { queryClient } from "@/composables/query/queryClient"; +import store from "@/stores"; +import { resetBrowserRumForTests } from "./initBrowserRum"; +import { ensureBrowserRum } from "./ensureBrowserRum"; + +const RUM = { + enabled: true, + client_token: "token", + application_id: "app", + site: "rum.example.com", + service: "web", + env: "test", + organization_identifier: "org", +}; + +const USER = { email: "user@example.com", given_name: "Ada", family_name: "Lovelace" }; + +beforeEach(() => { + resetBrowserRumForTests(); + queryClient.clear(); + rumInit.mockReset(); + logsInit.mockReset(); + Object.values(rumSdk).forEach((fn) => fn.mockReset()); + store.state.userInfo = USER; + getConfigFull.mockReset(); + getConfigFull.mockResolvedValue({ data: { version: "v1", rum: RUM } }); +}); + +describe("ensureBrowserRum", () => { + it("starts RUM from the org's full config and identifies the user without replay", async () => { + await ensureBrowserRum("acme"); + + expect(getConfigFull).toHaveBeenCalledWith("acme"); + expect(rumInit).toHaveBeenCalledTimes(1); + expect(logsInit).toHaveBeenCalledTimes(1); + expect(rumSdk.setUser).toHaveBeenCalledWith({ + id: "user@example.com", + name: "Ada Lovelace", + email: "user@example.com", + }); + expect(rumSdk.startSessionReplayRecording).not.toHaveBeenCalled(); + }); + + it("does not fetch the config again once RUM has started", async () => { + await ensureBrowserRum("acme"); + await ensureBrowserRum("beta"); + + expect(getConfigFull).toHaveBeenCalledTimes(1); + expect(rumInit).toHaveBeenCalledTimes(1); + }); + + it("does nothing without an org", async () => { + await ensureBrowserRum(undefined); + await ensureBrowserRum(""); + + expect(getConfigFull).not.toHaveBeenCalled(); + expect(rumInit).not.toHaveBeenCalled(); + }); + + it("neither starts RUM nor sets the user when the full config has it disabled", async () => { + getConfigFull.mockResolvedValue({ data: { version: "v1", rum: { ...RUM, enabled: false } } }); + + await ensureBrowserRum("acme"); + + expect(rumInit).not.toHaveBeenCalled(); + expect(rumSdk.setUser).not.toHaveBeenCalled(); + }); + + it("never rejects when the full config cannot be fetched", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + getConfigFull.mockRejectedValue(new Error("403")); + + await expect(ensureBrowserRum("acme")).resolves.toBeUndefined(); + + expect(rumInit).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/web/src/utils/rum/ensureBrowserRum.ts b/web/src/utils/rum/ensureBrowserRum.ts new file mode 100644 index 0000000000..48bf6c35c0 --- /dev/null +++ b/web/src/utils/rum/ensureBrowserRum.ts @@ -0,0 +1,41 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { openobserveRum } from "@openobserve/browser-rum"; +import { queryClient } from "@/composables/query/queryClient"; +import { configFullQuery } from "@/services/config.queries"; +import store from "@/stores"; +import { initBrowserRum, isBrowserRumStarted, type BrowserRumConfig } from "./initBrowserRum"; + +/** Starts RUM on a signed-in page outside MainLayout from the org's full config; never rejects. */ +export const ensureBrowserRum = async (orgIdentifier: string | undefined): Promise => { + if (!orgIdentifier || isBrowserRumStarted()) return; + try { + const fullConfig: { rum?: BrowserRumConfig } | undefined = await queryClient.fetchQuery( + configFullQuery(orgIdentifier), + ); + if (!initBrowserRum(fullConfig?.rum, store.state.API_ENDPOINT)) return; + const userInfo: { email?: string; given_name?: string; family_name?: string } = + store.state.userInfo; + // Standalone pages never record replay; only MainLayout and sign-in start it. + openobserveRum.setUser({ + id: userInfo.email, + name: userInfo.given_name + " " + userInfo.family_name, + email: userInfo.email, + }); + } catch (error) { + console.error("Failed to start RUM:", error); + } +}; diff --git a/web/src/utils/rum/initBrowserRum.spec.ts b/web/src/utils/rum/initBrowserRum.spec.ts new file mode 100644 index 0000000000..4738e743f2 --- /dev/null +++ b/web/src/utils/rum/initBrowserRum.spec.ts @@ -0,0 +1,269 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { BrowserRumConfig } from "./initBrowserRum"; + +const rumInit = vi.hoisted(() => vi.fn()); +const logsInit = vi.hoisted(() => vi.fn()); + +vi.mock("@openobserve/browser-rum", () => ({ + openobserveRum: { init: rumInit }, +})); +vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } })); + +const EXT = "chrome-extension://abcdefghijklmnop"; + +const RUM: BrowserRumConfig = { + enabled: true, + client_token: "token", + application_id: "app", + site: "rum.example.com", + service: "web", + env: "test", + version: "v0.70.0", + organization_identifier: "org", + api_version: "v1", + insecure_http: false, +}; + +// The SDK may be initialised once per page load, so each case gets a fresh module. +const loadHelper = async () => (await import("./initBrowserRum")).initBrowserRum; + +const rumBeforeSend = (): ((event: Record) => boolean) => + rumInit.mock.calls[0][0].beforeSend; + +const errorEvent = (stack: string) => ({ + type: "error", + view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, + error: { message: "boom", stack }, +}); + +beforeEach(() => { + vi.resetModules(); + rumInit.mockReset(); + logsInit.mockReset(); +}); + +describe("initBrowserRum", () => { + it("initialises the RUM and logs SDKs from the full config's rum block", async () => { + const initBrowserRum = await loadHelper(); + + expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true); + + expect(rumInit).toHaveBeenCalledTimes(1); + expect(rumInit.mock.calls[0][0]).toMatchObject({ + applicationId: "app", + clientToken: "token", + site: "rum.example.com", + organizationIdentifier: "org", + service: "web", + env: "test", + version: "v0.70.0", + apiVersion: "v1", + insecureHTTP: false, + startSessionReplayRecordingManually: true, + }); + expect(logsInit).toHaveBeenCalledTimes(1); + expect(logsInit.mock.calls[0][0]).toMatchObject({ + clientToken: "token", + site: "rum.example.com", + organizationIdentifier: "org", + version: "v0.70.0", + }); + }); + + it("initialises only once when the full config arrives again for another org", async () => { + const initBrowserRum = await loadHelper(); + + expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true); + expect(initBrowserRum({ ...RUM }, "http://localhost:5080")).toBe(true); + + expect(rumInit).toHaveBeenCalledTimes(1); + expect(logsInit).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["absent", undefined], + ["null", null], + ["disabled", { ...RUM, enabled: false }], + ])("does not initialise when rum is %s", async (_label, rum) => { + const initBrowserRum = await loadHelper(); + + expect(initBrowserRum(rum, "http://localhost:5080")).toBe(false); + + expect(rumInit).not.toHaveBeenCalled(); + expect(logsInit).not.toHaveBeenCalled(); + }); + + it("never throws into the config path and still starts logs when the RUM SDK throws", async () => { + const initBrowserRum = await loadHelper(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + rumInit.mockImplementation(() => { + throw new Error("rum boom"); + }); + + expect(() => initBrowserRum(RUM, "")).not.toThrow(); + + expect(logsInit).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("reports RUM off and retries it on the next config after the RUM SDK throws", async () => { + const initBrowserRum = await loadHelper(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + rumInit.mockImplementationOnce(() => { + throw new Error("rum boom"); + }); + + expect(initBrowserRum(RUM, "")).toBe(false); + expect(initBrowserRum(RUM, "")).toBe(true); + + expect(rumInit).toHaveBeenCalledTimes(2); + expect(logsInit).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + it.each([["client_token"], ["application_id"], ["site"]])( + "does not start RUM without %s and starts it from a corrected config", + async (field) => { + const initBrowserRum = await loadHelper(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(initBrowserRum({ ...RUM, [field]: "" }, "")).toBe(false); + expect(rumInit).not.toHaveBeenCalled(); + + expect(initBrowserRum(RUM, "")).toBe(true); + expect(rumInit).toHaveBeenCalledTimes(1); + expect(rumInit.mock.calls[0][0]).toMatchObject({ + applicationId: "app", + clientToken: "token", + }); + expect(logsInit).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }, + ); +}); + +describe("initBrowserRum beforeSend", () => { + beforeEach(async () => { + (await loadHelper())(RUM, ""); + }); + + it("drops an error whose every frame is a Chrome extension", () => { + const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`; + expect(rumBeforeSend()(errorEvent(stack))).toBe(false); + }); + + it("sends an error with at least one app frame", () => { + const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at setup (https://cloud.openobserve.ai/web/assets/index.js:3:4)`; + expect(rumBeforeSend()(errorEvent(stack))).toBe(true); + }); + + it("drops an error matching an ignored pattern", () => { + const event = { + type: "error", + view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, + error: { message: "ResizeObserver loop completed with undelivered notifications", stack: "" }, + }; + expect(rumBeforeSend()(event)).toBe(false); + }); + + it("still scrubs sign-in tokens from the view URL of an event it sends", () => { + const event = { + type: "view", + view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" }, + }; + expect(rumBeforeSend()(event)).toBe(true); + expect(event.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted"); + }); + + it("drops a JWT from a click's target name", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln"; + const event = { + type: "action", + view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, + action: { target: { name: `Copy ${jwt}` } }, + }; + expect(rumBeforeSend()(event)).toBe(true); + expect(event.action.target.name).toBe("Copy "); + }); + + it("drops a log matching an ignored pattern and scrubs the view URL of one it sends", () => { + const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as ( + log: Record, + ) => boolean; + expect(logsBeforeSend({ message: "ResizeObserver loop limit exceeded" })).toBe(false); + const log = { + message: "hello", + view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc" }, + }; + expect(logsBeforeSend(log)).toBe(true); + expect(log.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted"); + }); + + it("scrubs a public dashboard slug from a resource URL", () => { + const event = { + type: "resource", + view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, + resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/panels" }, + }; + expect(rumBeforeSend()(event)).toBe(true); + expect(event.resource.url).toBe( + "https://cloud.openobserve.ai/api/public_dashboards/redacted/panels", + ); + }); + + it("scrubs secrets from an error's message and resource URL", () => { + const event = { + type: "error", + view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, + error: { + message: "Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=abc", + stack: "", + resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" }, + }, + }; + expect(rumBeforeSend()(event)).toBe(true); + expect(event.error.message).toBe( + "Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=redacted", + ); + expect(event.error.resource.url).toBe( + "https://cloud.openobserve.ai/api/public_dashboards/redacted/data", + ); + }); + + it("scrubs secrets from a log's referrer, HTTP URL and message", () => { + const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as ( + log: Record, + ) => boolean; + const log = { + message: "GET https://cloud.openobserve.ai/web/public/dashboards/s3cr3t failed", + view: { + url: "https://cloud.openobserve.ai/web/logs", + referrer: "https://cloud.openobserve.ai/web/cb#id_token=abc", + }, + http: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" }, + }; + expect(logsBeforeSend(log)).toBe(true); + expect(log.view.referrer).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted"); + expect(log.http.url).toBe("https://cloud.openobserve.ai/api/public_dashboards/redacted/data"); + expect(log.message).toBe( + "GET https://cloud.openobserve.ai/web/public/dashboards/redacted failed", + ); + }); +}); diff --git a/web/src/utils/rum/initBrowserRum.ts b/web/src/utils/rum/initBrowserRum.ts new file mode 100644 index 0000000000..a2aba6c98a --- /dev/null +++ b/web/src/utils/rum/initBrowserRum.ts @@ -0,0 +1,184 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { openobserveRum } from "@openobserve/browser-rum"; +import { openobserveLogs } from "@openobserve/browser-logs"; +import { shouldPropagateTracing } from "./tracingOrigin"; +import { isIgnoredNoise } from "./ignoredNoisePatterns"; +import { scrubAuthParams } from "./scrubAuthParams"; +import { isExtensionOnlyError } from "./extensionFrames"; + +/** The `rum` block of the authenticated full config (`GET /api/{org}/config`). */ +export interface BrowserRumConfig { + enabled: boolean; + client_token: string; + application_id: string; + site: string; + service: string; + env: string; + version?: string; + organization_identifier: string; + api_version?: string; + insecure_http?: boolean; +} + +// Only the three dot-separated parts make a token, so a bare eyJ inside a word (surveyJsonBtn) is kept. +const JWT_PATTERN = /eyJ[\w-]*\.[\w-]+\.[\w-]*/g; + +const IGNORED_ERROR_PATTERNS = [ + /ResizeObserver loop/i, + /reo.dev/i, + /Cannot set properties of null \(setting 'innerHTML'\)/, +]; + +const IGNORED_LOG_PATTERNS = [/ResizeObserver loop/i]; + +let rumStarted = false; +let logsStarted = false; + +/** True once the RUM SDK has started in this page load. */ +export const isBrowserRumStarted = (): boolean => rumStarted; + +/** Starts the browser RUM and logs SDKs at most once per page load; returns whether RUM is on. Never throws. */ +export const initBrowserRum = ( + rum: BrowserRumConfig | null | undefined, + apiEndpoint: string, +): boolean => { + if (!rum?.enabled) return false; + if (rumStarted && logsStarted) return true; + + const version = rum.version || "0.0.1"; + const apiVersion = rum.api_version || "v1"; + const insecureHTTP = rum.insecure_http || false; + // Without a token or application id the SDK drops the init, and without a site it sends to its own default intake. + const hasLogsSettings = Boolean(rum.client_token && rum.site); + const hasRumSettings = hasLogsSettings && Boolean(rum.application_id); + if (!rumStarted && hasRumSettings) { + rumStarted = tryInit("RUM", () => + startRumSdk(rum, apiEndpoint, version, apiVersion, insecureHTTP), + ); + } else if (!rumStarted) { + console.error("The RUM configuration lacks a client token, application id or site."); + } + if (!logsStarted && hasLogsSettings) { + logsStarted = tryInit("logs", () => startLogsSdk(rum, version, apiVersion, insecureHTTP)); + } + return rumStarted; +}; + +export const resetBrowserRumForTests = (): void => { + rumStarted = false; + logsStarted = false; +}; + +const tryInit = (sdk: string, init: () => void): boolean => { + try { + init(); + return true; + } catch (error) { + console.error(`Failed to start the browser ${sdk} SDK:`, error); + return false; + } +}; + +const startRumSdk = ( + rum: BrowserRumConfig, + apiEndpoint: string, + version: string, + apiVersion: string, + insecureHTTP: boolean, +): void => + openobserveRum.init({ + applicationId: rum.application_id, + clientToken: rum.client_token, + site: rum.site, + organizationIdentifier: rum.organization_identifier, + service: rum.service, + env: rum.env, + version, + trackResources: true, + trackLongTasks: true, + trackUserInteractions: true, + actionNameAttribute: "data-test", + apiVersion, + insecureHTTP, + defaultPrivacyLevel: "allow", + // Session renewal drops the forced replay start, so sampling must keep every renewed session recording. + sessionReplaySampleRate: 100, + startSessionReplayRecordingManually: true, + // Cross-origin, the injected headers fail the CORS preflight and kill every API call. + allowedTracingUrls: shouldPropagateTracing(apiEndpoint, window.location.origin) + ? [ + { + match: apiEndpoint + "/api", + propagatorTypes: ["openobserve", "tracecontext"], + }, + ] + : [], + beforeSend: (event) => { + event.view.url = scrubAuthParams(event.view.url) ?? event.view.url; + event.view.referrer = scrubAuthParams(event.view.referrer); + if (event.type === "resource") { + event.resource.url = scrubAuthParams(event.resource.url) ?? event.resource.url; + } + if (event.type === "error") { + event.error.message = scrubAuthParams(event.error.message) ?? event.error.message; + if (event.error.resource) { + event.error.resource.url = + scrubAuthParams(event.error.resource.url) ?? event.error.resource.url; + } + } + if (event.type === "error" && isExtensionOnlyError(event.error?.stack)) return false; + if (event.type === "action" && event.action.target?.name) { + event.action.target.name = event.action.target.name.replace(JWT_PATTERN, ""); + } + if (event.type === "error") { + const errorMessage = event.error?.message || ""; + const errorStack = event.error?.stack || ""; + const shouldIgnore = IGNORED_ERROR_PATTERNS.some( + (pattern) => pattern.test(errorMessage) || pattern.test(errorStack), + ); + if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) return false; + } + return true; + }, + }); + +const startLogsSdk = ( + rum: BrowserRumConfig, + version: string, + apiVersion: string, + insecureHTTP: boolean, +): void => + openobserveLogs.init({ + clientToken: rum.client_token, + site: rum.site, + organizationIdentifier: rum.organization_identifier, + service: rum.service, + env: rum.env, + version, + forwardErrorsToLogs: true, + insecureHTTP, + apiVersion, + beforeSend: (log) => { + if (log.view?.url) log.view.url = scrubAuthParams(log.view.url) ?? log.view.url; + if (log.view?.referrer) log.view.referrer = scrubAuthParams(log.view.referrer); + if (log.http?.url) log.http.url = scrubAuthParams(log.http.url) ?? log.http.url; + log.message = scrubAuthParams(log.message) ?? log.message; + const logMessage = log.message || ""; + const shouldIgnore = IGNORED_LOG_PATTERNS.some((pattern) => pattern.test(logMessage)); + return !(shouldIgnore || isIgnoredNoise(logMessage)); + }, + }); diff --git a/web/src/views/AwsMarketplaceSetup.spec.ts b/web/src/views/AwsMarketplaceSetup.spec.ts index 795a9c6b94..9b0c4f4086 100644 --- a/web/src/views/AwsMarketplaceSetup.spec.ts +++ b/web/src/views/AwsMarketplaceSetup.spec.ts @@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { createStore } from "vuex"; import organizationsService from "@/services/organizations"; import awsMarketplace from "@/services/awsMarketplace"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; // Mock the services the two card-forms drive on submit. vi.mock("@/services/organizations", async (importOriginal) => { @@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => { }, }); }); +vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() })); vi.mock("@/services/awsMarketplace", () => ({ default: { linkSubscription: vi.fn(), @@ -273,6 +275,12 @@ describe("AwsMarketplaceSetup", () => { return wrapper; }; + it("starts RUM from the first org the caller belongs to", async () => { + await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]); + + expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1"); + }); + it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => { const wrapper = await mountSelectOrg(); const createForm = wrapper.findAllComponents({ name: "OForm" })[0]; diff --git a/web/src/views/AwsMarketplaceSetup.vue b/web/src/views/AwsMarketplaceSetup.vue index 3a815fe9ad..de4689ba85 100644 --- a/web/src/views/AwsMarketplaceSetup.vue +++ b/web/src/views/AwsMarketplaceSetup.vue @@ -197,6 +197,7 @@ import { raw, useI18nTyped } from "@/types/i18n"; import { getImageURL, useLocalOrganization } from "@/utils/zincutils"; import awsMarketplace from "@/services/awsMarketplace"; import organizationsService from "@/services/organizations"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import OButton from "@/lib/core/Button/OButton.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue"; @@ -265,6 +266,8 @@ export default defineComponent({ // Fetch user's organizations await fetchOrganizations(); + // RUM settings are instance-wide, so any org the user can read serves them. + void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier); }); onUnmounted(() => { diff --git a/web/src/views/AzureMarketplaceSetup.spec.ts b/web/src/views/AzureMarketplaceSetup.spec.ts index 8cbae7ff3d..ed16dd9d9e 100644 --- a/web/src/views/AzureMarketplaceSetup.spec.ts +++ b/web/src/views/AzureMarketplaceSetup.spec.ts @@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { createStore } from "vuex"; import organizationsService from "@/services/organizations"; import azureMarketplace from "@/services/azureMarketplace"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; // Mock the services the two card-forms drive on submit. vi.mock("@/services/organizations", async (importOriginal) => { @@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => { }, }); }); +vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() })); vi.mock("@/services/azureMarketplace", () => ({ default: { linkSubscription: vi.fn(), @@ -259,6 +261,12 @@ describe("AzureMarketplaceSetup", () => { return wrapper; }; + it("starts RUM from the first org the caller belongs to", async () => { + await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]); + + expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1"); + }); + it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => { const wrapper = await mountSelectOrg(); const createForm = wrapper.findAllComponents({ name: "OForm" })[0]; diff --git a/web/src/views/AzureMarketplaceSetup.vue b/web/src/views/AzureMarketplaceSetup.vue index 545d71da73..2fdccc4d1c 100644 --- a/web/src/views/AzureMarketplaceSetup.vue +++ b/web/src/views/AzureMarketplaceSetup.vue @@ -198,6 +198,7 @@ import { raw, useI18nTyped } from "@/types/i18n"; import { getImageURL, useLocalOrganization } from "@/utils/zincutils"; import azureMarketplace from "@/services/azureMarketplace"; import organizationsService from "@/services/organizations"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import OButton from "@/lib/core/Button/OButton.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue"; @@ -247,6 +248,8 @@ export default defineComponent({ // Fetch user's organizations await fetchOrganizations(); + // RUM settings are instance-wide, so any org the user can read serves them. + void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier); }); const fetchOrganizations = async () => { diff --git a/web/src/views/SlackConnectCallback.spec.ts b/web/src/views/SlackConnectCallback.spec.ts index 3d56285a40..afae8bea5d 100644 --- a/web/src/views/SlackConnectCallback.spec.ts +++ b/web/src/views/SlackConnectCallback.spec.ts @@ -22,10 +22,12 @@ import { makeSlackStore, ok } from "@/test/unit/helpers/slack"; import { isSlackOAuthCallbackMessage } from "@/utils/slackOAuth"; import { createStore } from "vuex"; import organizations from "@/services/organizations"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import SlackConnectCallback from "./SlackConnectCallback.vue"; vi.mock("@/services/slack", () => ({ default: { confirmConnection: vi.fn() } })); vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } })); +vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() })); const refusal = (status: number, data: Record = {}) => Object.assign(new Error("refused"), { @@ -70,6 +72,11 @@ afterEach(() => { }); describe("SlackConnectCallback", () => { + it("starts RUM from the full config of the org this page resolved", async () => { + await mountCallback(); + expect(ensureBrowserRum).toHaveBeenCalledWith("acme"); + }); + it("in a popup, posts {type, code, state} to its same-origin opener and closes", async () => { const opener = { postMessage: vi.fn() }; setOpener(opener); diff --git a/web/src/views/SlackConnectCallback.vue b/web/src/views/SlackConnectCallback.vue index 9a3ec46a19..3ed8af0150 100644 --- a/web/src/views/SlackConnectCallback.vue +++ b/web/src/views/SlackConnectCallback.vue @@ -20,6 +20,7 @@ import { useMutation } from "@tanstack/vue-query"; import { useRoute, useRouter } from "vue-router"; import SlackLinkCard from "@/components/slack/SlackLinkCard.vue"; import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import OButton from "@/lib/core/Button/OButton.vue"; import type { IllustrationName } from "@/lib/core/EmptyState/illustrations"; import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue"; @@ -128,6 +129,7 @@ const openSettings = (): void => { }; onMounted(() => { + whenResolved(() => void ensureBrowserRum(org.value)); const error = queryString(route.query.error); const code = queryString(route.query.code); const state = queryString(route.query.state); diff --git a/web/src/views/SlackLink.spec.ts b/web/src/views/SlackLink.spec.ts index c18bbdfd1e..4b741fdad1 100644 --- a/web/src/views/SlackLink.spec.ts +++ b/web/src/views/SlackLink.spec.ts @@ -22,12 +22,14 @@ import organizations from "@/services/organizations"; import slack from "@/services/slack"; import { leaveFor, recallLinkRequest } from "@/utils/slackLink"; import { axiosError, makeSlackStore, ok } from "@/test/unit/helpers/slack"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import SlackLink from "./SlackLink.vue"; vi.mock("@/services/slack", () => ({ default: { startLinkRequest: vi.fn(), resendLinkRequest: vi.fn() }, })); vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } })); +vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() })); vi.mock("@/utils/slackLink", async (importOriginal) => ({ ...(await importOriginal()), leaveFor: vi.fn(), @@ -77,6 +79,11 @@ afterEach(() => { }); describe("SlackLink (N7 pass-through)", () => { + it("starts RUM from the full config of the org this page resolved", async () => { + await mountLink(); + expect(ensureBrowserRum).toHaveBeenCalledWith("acme"); + }); + it("starts the request with the signature from ?r and hands off to Sign in with Slack", async () => { const w = await mountLink(); diff --git a/web/src/views/SlackLink.vue b/web/src/views/SlackLink.vue index c11a57bb63..44f29aa50a 100644 --- a/web/src/views/SlackLink.vue +++ b/web/src/views/SlackLink.vue @@ -21,6 +21,7 @@ import { useRoute } from "vue-router"; import SlackLinkCard from "@/components/slack/SlackLinkCard.vue"; import SlackLinkRefusal from "@/components/slack/SlackLinkRefusal.vue"; import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg"; +import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum"; import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue"; import { resendLinkRequestMutation, startLinkRequestMutation } from "@/services/slack.queries"; import { useI18nTyped } from "@/types/i18n"; @@ -89,7 +90,10 @@ const sendNewLink = async (): Promise => { } }; -onMounted(() => whenResolved(begin)); +onMounted(() => { + whenResolved(() => void ensureBrowserRum(org.value)); + whenResolved(begin); +});