From 54b84fc3f7cb103703ffb71d1a210d0c13198e59 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 15:26:08 +0100 Subject: [PATCH 1/7] Highlight the selected visualization tab with brand colors --- src/Pkg/Components/LogQueryBox.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pkg/Components/LogQueryBox.hs b/src/Pkg/Components/LogQueryBox.hs index 038b9c848..c22e8283e 100644 --- a/src/Pkg/Components/LogQueryBox.hs +++ b/src/Pkg/Components/LogQueryBox.hs @@ -342,7 +342,7 @@ logQueryBox_ config = do $ filter (not . hiddenHere) visTypes forM_ visible \v -> do let vizType = v.key - label_ [data_ "value" vizType, class_ "tab !shadow-none !border-strokeWeak flex gap-1 whitespace-nowrap"] do + label_ [data_ "value" vizType, class_ "tab !shadow-none !border-strokeWeak has-[:checked]:bg-fillBrand-weak! has-[:checked]:border-strokeBrand-strong! has-[:checked]:text-textBrand! flex gap-1 whitespace-nowrap"] do input_ $ [ type_ "radio" , name_ "visualization" From a538fd63aba603bd352431084ac0d3e6de66b29e Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 15:26:54 +0100 Subject: [PATCH 2/7] Add RUM audience breakdowns, investigation filters, and saved segments --- e2e/playwright.config.ts | 4 +- e2e/tests/rum-audience.spec.ts | 62 +++++ monoscope.cabal | 5 + src/Pages/Charts/Charts.hs | 6 +- src/Pages/Dashboards.hs | 32 +-- src/Pages/Rum.hs | 88 +++++++ src/Pkg/Components/Widget.hs | 16 +- src/Pkg/Parser.hs | 7 + src/Pkg/Rum.hs | 93 ++++++++ src/Web/Routes.hs | 7 + static/migrations/0254_rum_segments.sql | 6 + static/public/assets/css/tailwind.css | 37 +++ static/public/dashboards/rum.yaml | 221 +++++++++++++++--- .../integration/Pages/DashboardWidgetsSpec.hs | 58 ++++- 14 files changed, 582 insertions(+), 60 deletions(-) create mode 100644 e2e/tests/rum-audience.spec.ts create mode 100644 src/Pages/Rum.hs create mode 100644 src/Pkg/Rum.hs create mode 100644 static/migrations/0254_rum_segments.sql diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2adc7700c..480151656 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ trace: "on-first-retry", }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] }, testIgnore: /(container-identity|endpoint-analytics|host-map|infrastructure-time-window|metric-exemplars|metrics-catalog|tabs)\.spec\.ts$/ }, - { name: "chromium-fixtures", use: { ...devices["Desktop Chrome"] }, testMatch: /(container-identity|endpoint-analytics|host-map|infrastructure-time-window|metric-exemplars|metrics-catalog|tabs)\.spec\.ts$/, dependencies: ["chromium"], workers: 1 }, + { name: "chromium", use: { ...devices["Desktop Chrome"] }, testIgnore: /(container-identity|endpoint-analytics|host-map|infrastructure-time-window|metric-exemplars|metrics-catalog|tabs|rum-audience)\.spec\.ts$/ }, + { name: "chromium-fixtures", use: { ...devices["Desktop Chrome"] }, testMatch: /(container-identity|endpoint-analytics|host-map|infrastructure-time-window|metric-exemplars|metrics-catalog|tabs|rum-audience)\.spec\.ts$/, dependencies: ["chromium"], workers: 1 }, ], }); diff --git a/e2e/tests/rum-audience.spec.ts b/e2e/tests/rum-audience.spec.ts new file mode 100644 index 000000000..ef8dd106c --- /dev/null +++ b/e2e/tests/rum-audience.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { DEMO_PROJECT, sql } from "./helpers"; + +test("audience groups filter sessions, search users, and persist named segments", async ({ page }) => { + const app = `e2e-rum-audience-${Date.now()}`; + const segment = `${app} Mobile Safari`; + const cleanup = () => sql(`DELETE FROM otel_logs_and_spans WHERE project_id='${DEMO_PROJECT}' AND resource___service___name='${app}'; DELETE FROM projects.rum_segments WHERE project_id='${DEMO_PROJECT}' AND name='${segment}';`); + sql(`INSERT INTO otel_logs_and_spans (project_id,name,timestamp,start_time,end_time,resource___service___name,resource___user_agent___original,attributes___session___id,attributes___user___id,attributes___user___email,attributes___exception___type) + VALUES + ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com',NULL), + ('${DEMO_PROJECT}','browser error',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com','TypeError'), + ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}','Windows Chrome/151 Safari/537','${app}-bob','bob','bob@example.com',NULL), + ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}',NULL,'${app}-anonymous',NULL,NULL,NULL);`); + try { + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("console", message => { if (message.type() === "error" && /SyntaxError|parse error/i.test(message.text())) errors.push(message.text()); }); + await page.goto(`/p/${DEMO_PROJECT}/rum?tab=audience&var-app=${app}&since=1H`); + const toolbar = page.locator("#rum-segments"); + await expect(toolbar.getByRole("link", { name: "View sessions", exact: true })).toBeVisible(); + const browsers = page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "Browser", exact: true }) }); + const systems = page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "Operating system", exact: true }) }); + await expect(browsers.getByText("Unknown", { exact: true })).toBeVisible(); + await expect(browsers.locator("tbody tr").filter({ hasText: "Safari" })).toContainText("100 %"); + for (const width of [1920, 1280, 390]) { + await page.setViewportSize({ width, height: 1000 }); + await expect.poll(async () => { + const [browser, system] = await Promise.all([browsers.boundingBox(), systems.boundingBox()]); + return !!browser && !!system && (width === 1920 ? Math.abs(browser.y - system.y) < 3 : system.y > browser.y + browser.height); + }).toBe(true); + for (const table of [browsers, systems]) { + expect(await table.evaluate(el => el.getBoundingClientRect().right <= innerWidth && el.scrollWidth <= el.clientWidth + 1)).toBe(true); + } + } + await page.setViewportSize({ width: 1280, height: 1000 }); + await page.locator("[data-widget]").filter({ has: page.getByText("Identified users", { exact: true }) }).scrollIntoViewIfNeeded(); + const users = page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "User", exact: true }) }); + await expect(users.getByRole("link", { name: "alice@example.com" })).toBeVisible(); + await page.getByRole("searchbox", { name: "Search Identified users" }).fill("bob"); + await expect(users.locator("tbody tr").filter({ visible: true })).toHaveCount(1); + await expect(users.locator("tbody tr").filter({ visible: true })).toContainText("bob@example.com"); + await page.getByRole("searchbox", { name: "Search Identified users" }).fill(""); + await expect(users.locator("tbody tr").filter({ visible: true })).toHaveCount(2); + const safari = browsers.locator("tbody tr").filter({ hasText: "Safari" }); + await safari.focus(); + await safari.press("Enter"); + await expect(page).toHaveURL(/var-browser=Safari/); + await toolbar.getByText("Save segment", { exact: true }).click(); + await toolbar.getByLabel("Segment name").fill(segment); + await toolbar.getByRole("button", { name: "Save segment", exact: true }).click(); + await expect(toolbar.getByRole("link", { name: segment, exact: true })).toBeVisible(); + await page.reload(); + await expect(toolbar.getByRole("link", { name: segment, exact: true })).toBeVisible(); + await toolbar.getByRole("link", { name: "View errors", exact: true }).click(); + await expect(page).toHaveURL(/\/tab\/errors/); + await expect(page).toHaveURL(/var-browser=Safari/); + await toolbar.getByRole("link", { name: "View sessions", exact: true }).click(); + await expect(page).toHaveURL(/viz_type=sessions/); + expect(new URL(page.url()).searchParams.get("query")).toContain("Safari/"); + expect(errors).toEqual([]); + } finally { cleanup(); } +}); diff --git a/monoscope.cabal b/monoscope.cabal index 3fabfca8b..55699a44b 100644 --- a/monoscope.cabal +++ b/monoscope.cabal @@ -269,6 +269,7 @@ extra-source-files: static/migrations/0252_project_memory_page_identity.sql static/migrations/0253_project_memory_legacy_writes.sql static/migrations/0254_ingest_sampling.sql + static/migrations/0254_rum_segments.sql test/fixtures/github-app-test.pem test/fixtures/wiki-browser.mjs static/public/assets/web-components/dist/manifest.json @@ -380,6 +381,7 @@ library Pages.Projects Pages.Replay Pages.Reports + Pages.Rum Pages.ServiceMap Pages.Settings Pages.Share @@ -412,6 +414,7 @@ library Pkg.Prometheus Pkg.QueryCache Pkg.Queue + Pkg.Rum Pkg.Sampling Pkg.SchemaLearning.Catalog Pkg.SchemaLearning.Hot @@ -1024,6 +1027,7 @@ test-suite test-dev Pages.Projects Pages.Replay Pages.Reports + Pages.Rum Pages.ServiceMap Pages.Settings Pages.Share @@ -1056,6 +1060,7 @@ test-suite test-dev Pkg.Prometheus Pkg.QueryCache Pkg.Queue + Pkg.Rum Pkg.Sampling Pkg.SchemaLearning.Catalog Pkg.SchemaLearning.Hot diff --git a/src/Pages/Charts/Charts.hs b/src/Pages/Charts/Charts.hs index 0cdaeae2f..6f8e140ee 100644 --- a/src/Pages/Charts/Charts.hs +++ b/src/Pages/Charts/Charts.hs @@ -183,7 +183,7 @@ runQueryAST authCtx dbSource respDataType pid source binDensity environment serv case querySQLM of Just querySQL - | Just message <- rawSqlScopeError environment service querySQL -> + | Just message <- rawSqlScopeError environment service (fillQueryTemplate mappngSQL querySQL) -> pure (emptyMetricsFor now fromD toD){error = Just message} Just querySQL -> do let tbl = sourceTable source @@ -223,7 +223,7 @@ runRawQuery authCtx dbSource decoder cfg ast kql template mapping liveRange prog (from, to) = bounds canonical = M.fromList [("query_ast_filters", rawAstFilters cacheCfg components)] - <> variablePresets cfg.binDensity cfg.pid.toText from to [] cfg.currentTime + <> variablePresets cfg.binDensity cfg.pid.toText from to [(key, Just value) | (key, value) <- M.toList mapping, "var-" `T.isPrefixOf` key || key == "table-search"] cfg.currentTime <> mapping sql = fillQueryTemplate canonical template fetch = liftIO $ fetchMetricsDataWithProgress progress decoder sql cfg.currentTime from to authCtx dbSource >>= either throwIO pure @@ -755,7 +755,7 @@ queryMetricsStream dbSource dataTypeM pidM queryM querySQLM sinceM fromM toM sou maybe (first queryErrorText $ parseQueryDiagnosed source $ fillQueryTemplate mappingKQL $ maybeToMonoid $ Utils.nonEmptyT queryM) Left - (raw >>= rawSqlScopeError environment service) + (raw >>= rawSqlScopeError environment service . fillQueryTemplate mapping) scope = mkScopedQuery pid (fromD, toD) environment service cfg = (applyScopedQuery scope $ defSqlQueryCfg pid now source Nothing){binDensity = density, targetsTimefusion = usesTimefusionBackend authCtx.env.enableTimefusionReads dbSource} run emit = case parsed of diff --git a/src/Pages/Dashboards.hs b/src/Pages/Dashboards.hs index 71d24ae37..3e68f8aae 100644 --- a/src/Pages/Dashboards.hs +++ b/src/Pages/Dashboards.hs @@ -105,6 +105,7 @@ import Pages.GitSync qualified as GitSyncPage import Pages.Issues qualified as IssuesPage import Pages.LogExplorer.LogItem qualified as LogItem import Pages.Monitors qualified as Alerts +import Pages.Rum qualified as Rum import Pkg.Components.LogQueryBox (LogQueryBoxConfig (..), logQueryBox_) import Pkg.Components.ServiceMap (endpointDependencyMapPanel_) import Pkg.Components.Table (Table (..)) @@ -115,6 +116,7 @@ import Pkg.DeriveUtils (UUIDId (..), WrappedEnumSC (..), assetUrl, bulkActionSlu import Pkg.Metrics qualified as Metrics import Pkg.Parser (QueryComponents (..), SqlQueryCfg (..), applyScopedQuery, binDensityFor, browserKql, constantToKQLList, constantToSQLList, defSqlQueryCfg, fillQueryTemplate, fixedUTCTime, mkScopedQuery, parseQueryToComponents, variablePresetsKQL) import Pkg.Parser.Expr (sqlStringLit) +import Pkg.Rum qualified import Pkg.SchemaLearning.Catalog qualified as Catalog import Relude hiding (ask) import Servant (NoContent (..), ServerError, err302, err400, err404, errBody, errHeaders) @@ -328,7 +330,7 @@ dashboardPage_ pid dashId dash dashVM allParams = do dashboardTabStrip_ pidText dashIdText activeTabIdx tabs queryStr [] -- Variables section (pushed to the right, collapsible on mobile) - whenJust dash.variables \variables -> dashboardVariables_ pid dashId (isJust dash.tabs) variables [] + whenJust dash.variables \_ -> dashboardVariables_ pid dashId (isJust dash.tabs) (Rum.variablesForTab dash renderTabSlug) [] let widgetOrderUrl = "/p/" <> pidText <> "/dashboards/" <> dashIdText <> "/widgets_order" <> maybe "" ("?tab=" <>) renderTabSlug constants = constantsJson allParams @@ -347,11 +349,14 @@ dashboardPage_ pid dashId dash dashVM allParams = do -- Tab system with htmx lazy loading - only render active tab content. -- Grid re-init after a swap is the document-level listener's job (see this page's script). div_ - [ class_ "dashboard-tabs-container" - , id_ "dashboard-tabs-content" - , data_ "constants" constants - ] - $ whenJust (tabs !!? activeTabIdx) \activeTab -> + ( [ class_ "dashboard-tabs-container" + , id_ "dashboard-tabs-content" + , data_ "constants" constants + ] + <> [data_ "rum-audience" "" | dash.file == Just "rum.yaml" && renderTabSlug == Just "audience"] + ) + $ whenJust (tabs !!? activeTabIdx) \activeTab -> do + when (dash.file == Just "rum.yaml" && slugify activeTab.name `elem` ["audience", "errors"]) $ Rum.segmentToolbar_ pid allParams -- An unanswered required variable IS the tab's content, not a modal over -- it. Rendering the widgets underneath meant every one of them ran with -- the variable interpolated to '' and reported "no data in the selected @@ -452,7 +457,7 @@ dashboardPage_ pid dashId dash dashVM allParams = do animate: false, columnOpts: { breakpointForWindow: false, - breakpoints: [{w: 800, c: 6}, {w: 400, c: 1}], + breakpoints: gridEl.closest('[data-rum-audience]') ? [{w: 1500, c: 1}] : [{w: 800, c: 6}, {w: 400, c: 1}], layout: 'list' }, }, gridEl); @@ -512,7 +517,7 @@ dashboardPage_ pid dashId dash dashVM allParams = do animate: false, columnOpts: { breakpointForWindow: false, - breakpoints: [{w: 600, c: 6}, {w: 400, c: 1}], + breakpoints: nestedEl.closest('[data-rum-audience]') ? [{w: 600, c: 6}, {w: 320, c: 1}] : [{w: 600, c: 6}, {w: 400, c: 1}], layout: 'list' }, }, nestedEl); @@ -1255,7 +1260,7 @@ dashboardGetH pid dashId fileM fromDStr toDStr sinceStr hxRequest allParams = do (dashVM, dash) <- getDashAndVM pid dashId fileM -- Deep links to a widget must open its tab, rather than the default tab. - case (join (lookup "expand" allParams) >>= \wid -> fst =<< findWidgetInDashboard wid dash) <|> (slugify . (.name) <$> (dash.tabs >>= viaNonEmpty head)) of + case (join (lookup "expand" allParams) >>= \wid -> fst =<< findWidgetInDashboard wid dash) <|> (join (lookup "tab" allParams) >>= \slug -> slug <$ findTabBySlug (fold dash.tabs) slug) <|> (slugify . (.name) <$> (dash.tabs >>= viaNonEmpty head)) of Just tabSlug -> do let redirectUrl = "/p/" <> pid.toText <> "/dashboards/" <> dashId.toText <> "/tab/" <> tabSlug <> queryStringFrom allParams throwError $ err302{errHeaders = [("Location", encodeUtf8 redirectUrl)]} @@ -2278,7 +2283,7 @@ rumRedirectH pid segments params = case (filter (not . T.null) segments, param " where param k = join $ lookup k params dashboard = entrypointRedirectGetH "rum.yaml" "Real User Monitoring" ["rum", "browser", "frontend", "web-vitals"] pid params - query = maybe browserKql (("attributes.session.id == " <>) . kqlQuoted) (param "session") + query = T.intercalate " and " $ filter (not . T.null) [maybe browserKql (("attributes.session.id == " <>) . kqlQuoted) (param "session"), Pkg.Rum.audienceKql $ Pkg.Rum.audienceFilters params] sessions = pure $ addHeader (timeScopedUrl ("/p/" <> pid.toText <> "/log_explorer") [("viz_type", "sessions"), ("query", query)] (param "from") (param "to") (param "since")) NoContent @@ -2805,15 +2810,16 @@ dashboardTabGetH pid dashId tabSlug fileM fromDStr toDStr sinceStr hxRequest hxT queryStr = queryStringFrom $ filter (\(k, _) -> k `notElem` [activeTabSlugKey, "expand"]) paramsWithTab fragment = do dashboardTabStrip_ pid.toText dashId.toText activeTabIdx (fold dash''.tabs) queryStr [hxSwapOob_ "outerMorph"] - when (hxTriggerName == Just "dashboard-variable-option") - $ whenJust dash''.variables \variables -> dashboardVariables_ pid dashId True variables [hxSwapOob_ "true"] - div_ [class_ "dashboard-tabs-container", id_ "dashboard-tabs-content", data_ "constants" $ constantsJson paramsWithTab] + when (hxTriggerName == Just "dashboard-variable-option" || dash''.file == Just "rum.yaml") + $ whenJust dash''.variables \_ -> dashboardVariables_ pid dashId True (Rum.variablesForTab dash'' $ Just tabSlug) [hxSwapOob_ "true"] + div_ ([class_ "dashboard-tabs-container", id_ "dashboard-tabs-content", data_ "constants" $ constantsJson paramsWithTab] <> [data_ "rum-audience" "" | dash''.file == Just "rum.yaml" && tabSlug == "audience"]) $ case findVarToPrompt (snd <$> activeTabInfo) (fold dash''.variables) of Just v -> do whenJust activeTabName breadcrumbSuffixOob_ variablePickerModal_ pid dashId (Just tabSlug) paramsWithTab v Nothing -> whenJust (dash''.tabs >>= (!!? activeTabIdx)) \activeTab -> do breadcrumbSuffixOob_ activeTab.name + when (dash''.file == Just "rum.yaml" && tabSlug `elem` ["audience", "errors"]) $ Rum.segmentToolbar_ pid paramsWithTab widgetCanvas_ pid ("tab-panel-" <> dashId.toText <> "-" <> show activeTabIdx) activeTab.widgets addRespHeaders $ navigationResponse hxRequest page fragment diff --git a/src/Pages/Rum.hs b/src/Pages/Rum.hs new file mode 100644 index 000000000..e119f7206 --- /dev/null +++ b/src/Pages/Rum.hs @@ -0,0 +1,88 @@ +module Pages.Rum (SegmentForm (..), segmentsGetH, segmentsPostH, segmentsDeleteH, segmentToolbar_, variablesForTab) where + +import Data.Effectful.Hasql qualified as Hasql +import Data.List (lookup) +import Data.Text qualified as T +import Effectful.Error.Static (throwError) +import Hasql.Interpolate qualified as HI +import Lucid +import Lucid.Aria qualified as Aria +import Lucid.Htmx +import Models.Projects.Dashboards qualified as Dashboards +import Models.Projects.Projects qualified as Projects +import Pkg.Rum (AudienceFilters (..), audienceFilters, audienceParams) +import Relude +import Servant (err400, errBody) +import System.Types +import Utils (faSprite_, queryStringFrom, toUriStr) +import Web.FormUrlEncoded (FromForm (..), parseUnique) + + +data SegmentForm = SegmentForm {name :: Text, filters :: AudienceFilters} + deriving stock (Generic, Show) + + +instance FromForm SegmentForm where + fromForm form = SegmentForm <$> parseUnique "name" form <*> fromForm form + + +variablesForTab :: Dashboards.Dashboard -> Maybe Text -> [Dashboards.Variable] +variablesForTab dash tab = filter (\v -> dash.file /= Just "rum.yaml" || tab == Just "audience" || tab == Just "errors" || v.key == "app") $ fold dash.variables + + +segmentToolbar_ :: Projects.ProjectId -> [(Text, Maybe Text)] -> Html () +segmentToolbar_ pid params = + div_ + [ id_ "rum-segments" + , hxGet_ $ "/p/" <> pid.toText <> "/rum/segments" <> queryStringFrom params + , hxTrigger_ "load, update-query from:window" + , hxVals_ "js:{...Object.fromEntries(new URLSearchParams(location.search))}" + , hxSwap_ "innerHTML" + , class_ "px-4 py-2 border-b border-strokeWeak" + ] + mempty + + +segmentsGetH :: Projects.ProjectId -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (Html ())) +segmentsGetH pid params = do + _ <- Projects.sessionAndProject pid + segments <- Hasql.interp @[(Text, HI.AsJsonb AudienceFilters)] [HI.sql|SELECT name, filters FROM projects.rum_segments WHERE project_id = #{pid} ORDER BY name|] + let current = audienceFilters params + root = "/p/" <> pid.toText <> "/rum" + window = filter (\(key, _) -> key `elem` ["from", "to", "since", "environment"]) params + tab = [("tab", Just $ fromMaybe "audience" $ join (lookup "activeTabSlug" params) <|> join (lookup "tab" params))] + addRespHeaders $ div_ [class_ "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"] do + nav_ [Aria.label_ "Saved audience segments", class_ "flex flex-wrap items-center gap-2"] do + span_ [class_ "text-textWeak"] "Segments" + forM_ segments \(name, HI.AsJsonb filters) -> do + a_ [href_ $ root <> queryStringFrom (tab <> audienceParams filters <> window), class_ $ "rounded px-2 py-1 hover:bg-fillWeak " <> if filters == current then "bg-fillBrand-weak text-textBrand font-semibold" else "text-textStrong"] $ toHtml name + button_ [type_ "button", class_ "btn btn-ghost btn-xs", Aria.label_ $ "Delete segment " <> name, hxDelete_ $ root <> "/segments/" <> toUriStr name <> queryStringFrom params, hxTarget_ "#rum-segments", hxSwap_ "innerHTML"] $ faSprite_ "xmark" "regular" "h-3 w-3" + details_ [class_ "relative"] do + summary_ [class_ "cursor-pointer text-textBrand"] "Save segment" + form_ [class_ "absolute z-20 mt-2 p-3 w-64 rounded-lg border border-strokeWeak bg-bgRaised shadow-lg flex flex-col gap-3", hxPost_ $ root <> "/segments" <> queryStringFrom params, hxTarget_ "#rum-segments", hxSwap_ "innerHTML"] do + label_ [class_ "flex flex-col gap-1"] do + "Segment name" + input_ [name_ "name", required_ "", maxlength_ "100", class_ "input input-sm w-full", placeholder_ "Mobile Safari"] + forM_ ([("app", current.app), ("browser", current.browser), ("os", current.os), ("device", current.device), ("errored", Just $ if current.errored then "true" else "false")] :: [(Text, Maybe Text)]) \(key, value) -> input_ [type_ "hidden", name_ key, value_ $ fromMaybe "" value] + button_ [type_ "submit", class_ "btn btn-primary btn-sm"] "Save segment" + a_ [class_ "text-textBrand hover:underline", href_ $ root <> "/sessions" <> queryStringFrom (audienceParams current <> window)] "View sessions" + a_ [class_ "text-textBrand hover:underline", href_ $ root <> queryStringFrom ([("tab", Just "errors")] <> audienceParams current <> window)] "View errors" + when (any isJust [current.app, current.browser, current.os, current.device] || current.errored) + $ a_ [class_ "text-textWeak hover:underline", href_ $ root <> queryStringFrom (tab <> window)] "Clear filters" + + +segmentsPostH :: Projects.ProjectId -> [(Text, Maybe Text)] -> SegmentForm -> ATAuthCtx (RespHeaders (Html ())) +segmentsPostH pid params form = do + _ <- Projects.sessionAndProject pid + let name = T.strip form.name + filters = audienceFilters $ audienceParams form.filters + when (T.null name || T.length name > 100) $ throwError err400{errBody = "Segment name must contain 1–100 characters."} + Hasql.interpExecute_ [HI.sql|INSERT INTO projects.rum_segments (project_id, name, filters) VALUES (#{pid}, #{name}, #{HI.AsJsonb filters}) ON CONFLICT (project_id, name) DO UPDATE SET filters = EXCLUDED.filters|] + segmentsGetH pid params + + +segmentsDeleteH :: Projects.ProjectId -> Text -> [(Text, Maybe Text)] -> ATAuthCtx (RespHeaders (Html ())) +segmentsDeleteH pid name params = do + _ <- Projects.sessionAndProject pid + Hasql.interpExecute_ [HI.sql|DELETE FROM projects.rum_segments WHERE project_id = #{pid} AND name = #{name}|] + segmentsGetH pid params diff --git a/src/Pkg/Components/Widget.hs b/src/Pkg/Components/Widget.hs index 3faa8d15a..c4c12a0d3 100644 --- a/src/Pkg/Components/Widget.hs +++ b/src/Pkg/Components/Widget.hs @@ -37,7 +37,7 @@ import Models.Apis.Monitors qualified as Monitors import Models.Projects.Projects qualified as Projects import NeatInterpolation import Pages.Charts.Charts qualified as Charts -import Pages.Components (copyButton_, headerRow_) +import Pages.Components (copyButton_, headerRow_, searchInput_) import Pkg.Components.TimePicker qualified as TimePicker import Pkg.DeriveUtils (JsonValueSchema (..), WrappedEnumSC (..)) import Pkg.Parser (ToQueryText (..), parseQueryToAST, replacePlaceholders) @@ -311,6 +311,7 @@ data Widget = Widget , standalone :: Maybe Bool -- Not used in a grid stack , allowZoom :: Maybe Bool -- Allow zooming in the chart , columns :: Maybe [TableColumn] -- Table columns + , searchable :: Maybe Bool , onRowClick :: Maybe RowClickAction -- Action when table row is clicked -- Alert fields (populated from QueryMonitor at render time) , alertId :: Maybe Text -- Linked QueryMonitor ID @@ -1190,17 +1191,19 @@ renderTable widget = do $ div_ [ class_ "h-full overflow-auto p-3" , hxGet_ $ widgetFetchUrl eagerWidget - , hxTrigger_ $ bool "intersect once, update-query from:window, table-sort" "update-query from:window, table-sort" (isJust widget.html) + , hxTrigger_ $ bool "intersect once, update-query from:window, table-sort" "update-query from:window, table-sort" (isJust widget.html) <> memptyIfFalse (widget.searchable == Just True) ", input delay:250ms, search" , hxTarget_ $ "#" <> tableId , hxSelect_ $ "#" <> tableId , hxSwap_ "outerHTML" , term "hx-sync" "this:replace" - , term "hx-vals" "js:{...Object.fromEntries(new URLSearchParams(location.search)), ...JSON.parse(this.closest('[data-constants]')?.dataset.constants || '{}'), 'table-sort': this.dataset.tableSort || ''}" + , term "hx-vals" "js:{...Object.fromEntries(new URLSearchParams(location.search)), ...JSON.parse(this.closest('[data-constants]')?.dataset.constants || '{}'), 'table-sort': this.dataset.tableSort || '', 'table-search': this.querySelector('input[type=search]')?.value || ''}" , data_ "table-fetch" "" ] - $ case widget.html of - Just html -> toHtmlRaw html - Nothing -> div_ [class_ "flex h-full min-h-32 items-center justify-center", id_ tableId] $ loadingIndicator_ LdSM LdSpinner + $ do + when (widget.searchable == Just True) $ searchInput_ "" ("Search " <> fromMaybe "rows" widget.title) [] + case widget.html of + Just html -> toHtmlRaw html + Nothing -> div_ [class_ "flex h-full min-h-32 items-center justify-center", id_ tableId] $ loadingIndicator_ LdSM LdSpinner -- | Render stat widget content with HTMX lazy loading support @@ -1635,6 +1638,7 @@ renderTableWithDataAndParams widget dataRows params = do ( [class_ $ "hover" <> memptyIfFalse (isJust widget.onRowClick) " cursor-pointer" <> memptyIfFalse isCurrent " bg-fillBrand-weak font-semibold"] <> [term "aria-current" "true" | isCurrent] <> rowNavAttrs rowValue + <> memptyIfFalse (isJust widget.onRowClick) [tabindex_ "0", Aria.label_ $ "Filter by " <> rowValue, term "_" " on keydown[key=='Enter' or key==' '] halt the event then call me.click() end "] ) $ ifor_ columns \idx col -> unless (or col.hidden) diff --git a/src/Pkg/Parser.hs b/src/Pkg/Parser.hs index d70cc0616..55cce765d 100644 --- a/src/Pkg/Parser.hs +++ b/src/Pkg/Parser.hs @@ -14,6 +14,7 @@ import Models.Projects.Projects qualified as Projects import Pkg.Deriving (WrappedEnumSC (..)) import Pkg.Parser.Expr import Pkg.Parser.Stats +import Pkg.Rum qualified as Rum import PyF (fmt) import Relude import Relude.Extra.Foldable1 (minimumOn1) @@ -964,6 +965,8 @@ variablePresets :: BinDensity -> Text -> Maybe UTCTime -> Maybe UTCTime -> [(Tex variablePresets density pid mf mt allParams currentTime = let fmtUTC = maybe "" formatUTC andPrefix = (" AND " <>) + audience = Rum.audienceFilters allParams + audienceSessions = Rum.audienceSessionsSql browserSql errorSql audience bound op t field' = field' <> " " <> op <> " '" <> formatUTC t <> "'" clause field = case catMaybes [bound ">=" <$> mf, bound "<=" <$> mt] of [] -> "" @@ -979,6 +982,10 @@ variablePresets density pid mf mt allParams currentTime = , ("pageview_filter", pageViewSql) , ("error_filter", errorSql) , ("browser_kql", browserKql) + , ("audience_sessions", audienceSessions) + , ("audience_kql_literal", sqlStringLit $ Rum.audienceKql audience) + , ("table_search", sqlStringLit $ maybeToMonoid $ join $ Map.lookup "table-search" $ Map.fromList allParams) + , ("audience_filter", if any isJust [audience.browser, audience.os, audience.device] || audience.errored then "attributes___session___id IN (" <> audienceSessions <> "SELECT session FROM audience_sessions)" else "TRUE") ] <> (allParams <&> second maybeToMonoid) diff --git a/src/Pkg/Rum.hs b/src/Pkg/Rum.hs new file mode 100644 index 000000000..dfdc1d6d2 --- /dev/null +++ b/src/Pkg/Rum.hs @@ -0,0 +1,93 @@ +module Pkg.Rum (AudienceFilters (..), audienceFilters, audienceParams, audienceSessionsSql, audienceKql) where + +import Data.Aeson qualified as AE +import Data.List (lookup) +import Data.Text qualified as T +import Pkg.Parser.Expr (sqlStringLit) +import Relude +import Utils (kqlQuoted, nonEmptyT) +import Web.FormUrlEncoded (FromForm) + + +data AudienceFilters = AudienceFilters + { app :: Maybe Text + , browser :: Maybe Text + , os :: Maybe Text + , device :: Maybe Text + , errored :: Bool + } + deriving stock (Eq, Generic, Show) + deriving anyclass (AE.FromJSON, AE.ToJSON, FromForm) + + +audienceFilters :: [(Text, Maybe Text)] -> AudienceFilters +audienceFilters params = AudienceFilters (param "app") (param "browser") (param "os") (param "device") (param "errors" == Just "true") + where + param key = nonEmptyT $ join $ lookup ("var-" <> key) params + + +audienceParams :: AudienceFilters -> [(Text, Maybe Text)] +audienceParams f = [("var-app", f.app), ("var-browser", f.browser), ("var-os", f.os), ("var-device", f.device), ("var-errors", Just $ if f.errored then "true" else "false")] + + +-- Ordered rules: embedded browser/OS names must not win over their host family. +data AgentRule = Contains (NonEmpty Text) | Tablet | AnyAgent + + +families :: [(Text, [(Text, AgentRule)])] +families = + [ ("browser", [("Edge", Contains ("Edg/" :| ["Edge/", "EdgiOS/", "EdgA/"])), ("Opera", Contains ("OPR/" :| ["Opera", "OPiOS/"])), ("Samsung Internet", Contains ("SamsungBrowser" :| [])), ("Firefox", Contains ("Firefox/" :| ["FxiOS/"])), ("Chrome", Contains ("Chrome/" :| ["CriOS/"])), ("Safari", Contains ("Safari/" :| []))]) + , ("os", [("Windows", Contains ("Windows" :| [])), ("iOS", Contains ("iPhone" :| ["iPad", "iPod"])), ("macOS", Contains ("Mac OS X" :| ["Macintosh"])), ("Android", Contains ("Android" :| [])), ("ChromeOS", Contains ("CrOS" :| [])), ("Linux", Contains ("Linux" :| ["X11"]))]) + , ("device", [("Tablet", Tablet), ("Mobile", Contains ("Mobile" :| ["iPhone", "iPod"])), ("Desktop", AnyAgent)]) + ] + + +audienceSessionsSql :: Text -> Text -> AudienceFilters -> Text +audienceSessionsSql browserSql errorSql f = + "WITH rum_session_events AS (SELECT attributes___session___id AS session, " + <> "MAX(COALESCE(NULLIF(attributes___user_agent___original, ''), NULLIF(resource___user_agent___original, ''), '')) AS ua, " + <> "MAX(NULLIF(attributes___user___id, '')) AS user_id, MAX(NULLIF(attributes___user___email, '')) AS email, MAX(NULLIF(attributes___user___full_name, '')) AS full_name, " + <> "MAX(timestamp) AS last_seen, MAX(CASE WHEN " + <> errorSql + <> " THEN 1 ELSE 0 END) AS errored " + <> "FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND " + <> browserSql + <> " AND NULLIF(attributes___session___id, '') IS NOT NULL GROUP BY attributes___session___id), " + <> "rum_sessions AS (SELECT *, " + <> T.intercalate ", " [classify rules <> " AS " <> key | (key, rules) <- families] + <> " FROM rum_session_events), " + <> "audience_sessions AS (SELECT * FROM rum_sessions WHERE " + <> T.intercalate " AND " ("TRUE" : [key <> " = " <> sqlStringLit val | (key, Just val) <- [("browser", f.browser), ("os", f.os), ("device", f.device)]] <> ["errored = 1" | f.errored]) + <> ") " + where + classify rules = "CASE WHEN ua = '' THEN 'Unknown' " <> T.concat ["WHEN " <> match rule <> " THEN " <> sqlStringLit label <> " " | (label, rule) <- rules] <> "ELSE 'Other' END" + match Tablet = "(ua ILIKE '%iPad%' OR (ua ILIKE '%Android%' AND ua NOT ILIKE '%Mobile%'))" + match AnyAgent = "TRUE" + match (Contains tokens) = "(" <> T.intercalate " OR " ["ua ILIKE " <> sqlStringLit ("%" <> token <> "%") | token <- toList tokens] <> ")" + + +-- The same ordered family rules drive session Explorer links. +audienceKql :: AudienceFilters -> Text +audienceKql f = + T.intercalate " and " + $ ["resource.service.name == " <> kqlQuoted value | value <- toList f.app] + <> [predicate rules value | (key, rules) <- families, Just value <- [join $ lookup key [("browser", f.browser), ("os", f.os), ("device", f.device)]]] + <> ["(status_code == \"ERROR\" or level matches /^error$/ or attributes.exception.type != null)" | f.errored] + where + attribute = "attributes.user_agent.original" + resource = "resource.user_agent.original" + predicate rules value + | value == "Unknown" = "(isempty(" <> attribute <> ") and isempty(" <> resource <> "))" + | value /= "Other" && isNothing (lookup value rules) = "1 == 0" + | otherwise = "((isnotempty(" <> attribute <> ") and " <> select attribute <> ") or (isempty(" <> attribute <> ") and isnotempty(" <> resource <> ") and " <> select resource <> "))" + where + (before, rest) = break ((== value) . fst) rules + select field = "(" <> T.intercalate " and " ([test False field rule | (_, rule) <- before] <> [maybe "1 == 1" (test True field . snd) $ listToMaybe rest]) <> ")" + test positive field (Contains tokens) = "(" <> T.intercalate (if positive then " or " else " and ") [field <> (if positive then " contains " else " !contains ") <> kqlQuoted token | token <- toList tokens] <> ")" + test positive _ AnyAgent = if positive then "1 == 1" else "1 == 0" + test positive field Tablet = + let has token = field <> " contains " <> kqlQuoted token + lacks token = field <> " !contains " <> kqlQuoted token + in if positive + then "(" <> has "iPad" <> " or (" <> has "Android" <> " and " <> lacks "Mobile" <> "))" + else "(" <> lacks "iPad" <> " and (" <> lacks "Android" <> " or " <> has "Mobile" <> "))" diff --git a/src/Web/Routes.hs b/src/Web/Routes.hs index 1b6968462..d7449ff08 100644 --- a/src/Web/Routes.hs +++ b/src/Web/Routes.hs @@ -77,6 +77,7 @@ import Pages.Projects qualified as ListProjects import Pages.Projects qualified as ManageMembers import Pages.Replay qualified as Replay import Pages.Reports qualified as Reports +import Pages.Rum qualified as Rum import Pages.ServiceMap qualified as ServiceMap import Pages.Settings qualified as Settings import Pages.Share qualified as Share @@ -473,6 +474,9 @@ data CookieProtectedRoutes mode = CookieProtectedRoutes , slackIdentityPost :: mode :- "slack" :> "link" :> Capture "linkId" (UUIDId "slack_link") :> ReqBody '[FormUrlEncoded] Slack.SlackLinkForm :> Post '[HTML] (RespHeaders (Html ())) , slackLinkProjectGet :: mode :- "slack" :> "oauth" :> "callback" :> QPT "code" :> QPT "state" :> LocationRedirect BotUtils.BotResponse , endpointDetailsRedirect :: mode :- "p" :> ProjectId :> "endpoints" :> "details" :> AllQueryParams :> LocationRedirect NoContent + , rumSegmentsGet :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> AllQueryParams :> Get '[HTML] (RespHeaders (Html ())) + , rumSegmentsPost :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> AllQueryParams :> ReqBody '[FormUrlEncoded] Rum.SegmentForm :> Post '[HTML] (RespHeaders (Html ())) + , rumSegmentsDelete :: mode :- "p" :> ProjectId :> "rum" :> "segments" :> Capture "name" Text :> AllQueryParams :> Delete '[HTML] (RespHeaders (Html ())) , rumRedirect :: mode :- "p" :> ProjectId :> "rum" :> CaptureAll "view" Text :> AllQueryParams :> LocationRedirect NoContent , dashboardNewGet :: mode :- "p" :> ProjectId :> "dashboards" :> "new" :> Get '[HTML] (RespHeaders Dashboards.DashboardNew) , dashboardsGet :: mode :- "p" :> ProjectId :> "dashboards" :> Capture "dashboard_id" Dashboards.DashboardId :> QPT "file" :> QPT "from" :> QPT "to" :> QPT "since" :> HXRequest :> AllQueryParams :> Get '[HTML] (RespHeaders (PageCtx Dashboards.DashboardGet)) @@ -977,6 +981,9 @@ cookieProtectedServer = , slackLinkProjectGet = Slack.linkProjectGetH , endpointDetailsRedirect = Dashboards.entrypointRedirectGetH "endpoint-stats.yaml" "Endpoint Analytics" ["endpoints", "http", "events"] , rumRedirect = Dashboards.rumRedirectH + , rumSegmentsGet = Rum.segmentsGetH + , rumSegmentsPost = Rum.segmentsPostH + , rumSegmentsDelete = Rum.segmentsDeleteH , dashboardsGet = Dashboards.dashboardGetH , dashboardNewGet = Dashboards.dashboardNewGetH , dashboardsGetList = Dashboards.dashboardsGetH diff --git a/static/migrations/0254_rum_segments.sql b/static/migrations/0254_rum_segments.sql new file mode 100644 index 000000000..90d78c861 --- /dev/null +++ b/static/migrations/0254_rum_segments.sql @@ -0,0 +1,6 @@ +CREATE TABLE projects.rum_segments ( + project_id uuid NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 100), + filters jsonb NOT NULL, + PRIMARY KEY (project_id, name) +); diff --git a/static/public/assets/css/tailwind.css b/static/public/assets/css/tailwind.css index b5caafa36..1463f542e 100644 --- a/static/public/assets/css/tailwind.css +++ b/static/public/assets/css/tailwind.css @@ -2588,6 +2588,43 @@ table td a[href]:not(.btn):not([data-no-preview]) { --tooltip-z: 100; --tooltip- container: dashboard / inline-size; } +[data-rum-audience] table { + table-layout: fixed; +} +[data-rum-audience] :is(th, td) { + overflow-wrap: anywhere; + white-space: normal; +} +[data-rum-audience] th > div, +[data-rum-audience] th > div > span { + min-width: 0; +} +@container dashboard (max-width: 400px) { + [data-rum-audience] > .grid-stack { + width: 100%; + margin-inline: 0 !important; + } + [data-rum-audience] .grid-stack-item > .grid-stack-item-content { + inset: auto !important; + } + [data-rum-audience] :is(th, td) { + padding-inline: 0.375rem; + } + [data-rum-audience] .nested-grid { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100% !important; + height: auto !important; + margin-inline: 0 !important; + } + [data-rum-audience] .nested-grid.grid-stack > .grid-stack-item:not([data-mobile-autofit]) { + width: auto !important; + min-width: 0; + height: 7.5rem !important; + min-height: 7.5rem !important; + } +} + .stat-widget { container: stat / inline-size; } diff --git a/static/public/dashboards/rum.yaml b/static/public/dashboards/rum.yaml index 134399927..063b1dd1d 100644 --- a/static/public/dashboards/rum.yaml +++ b/static/public/dashboards/rum.yaml @@ -30,6 +30,31 @@ variables: LIMIT 100 multi: false + - key: browser + title: Browser + type: values + multi: false + options: [[Chrome], [Edge], [Firefox], [Opera], [Safari], [Samsung Internet], [Other], [Unknown]] + + - key: os + title: Operating system + type: values + multi: false + options: [[Windows], [iOS], [macOS], [Android], [ChromeOS], [Linux], [Other], [Unknown]] + + - key: device + title: Device + type: values + multi: false + options: [[Desktop], [Mobile], [Tablet], [Other], [Unknown]] + + - key: errors + title: Session health + type: values + multi: false + options: [["false", All sessions], ["true", Sessions with errors]] + value: "false" + widgets: [] tabs: @@ -344,7 +369,8 @@ tabs: title: Browser error volume icon: triangle-exclamation query: *app - sql: *browser_error_series + sql: | + SELECT extract(epoch from time_bucket('{{rollup_interval}}', timestamp))::integer, 'value', count(*)::float FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} AND {{audience_filter}} GROUP BY time_bucket('{{rollup_interval}}', timestamp) ORDER BY time_bucket('{{rollup_interval}}', timestamp) DESC unit: errors summarize_by: sum series_intent: error @@ -355,7 +381,7 @@ tabs: icon: users query: *app sql: | - SELECT COUNT(DISTINCT attributes___session___id)::float FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} + SELECT COUNT(DISTINCT attributes___session___id)::float FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} AND {{audience_filter}} unit: sessions layout: { w: 4, h: 3 } @@ -364,7 +390,7 @@ tabs: icon: user query: *app sql: | - SELECT COUNT(DISTINCT COALESCE(attributes___user___id, attributes___user___email))::float FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} + SELECT COUNT(DISTINCT COALESCE(attributes___user___id, attributes___user___email))::float FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} AND {{audience_filter}} unit: users layout: { w: 4, h: 3 } @@ -401,50 +427,177 @@ tabs: LEFT(regexp_replace(COALESCE(NULLIF(attributes___exception___message, ''), NULLIF(status_message, ''), name, 'No error message'), '[0-9a-fA-F-]{8,}|[0-9]+', '', 'g'), 300) AS message, MIN(name) AS span_name, COUNT(*) AS n, COUNT(DISTINCT attributes___session___id)::text AS sessions, MAX(timestamp)::text AS last_seen FROM otel_logs_and_spans - WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} + WHERE {{query_ast_filters}} AND {{browser_filter}} AND {{error_filter}} AND {{audience_filter}} GROUP BY 1, 2, 3 ORDER BY COUNT(*) DESC LIMIT 50 ) AS groups ORDER BY n DESC - # User-agent families, coarse on purpose. Arm order matters: Edge and Opera embed "Chrome", - # Chrome embeds "Safari", iOS says "like Mac OS X", Android embeds "Linux". - name: Audience icon: users widgets: + - type: group + title: Audience in this window + description: Each session counts once. Unknown means no user agent was supplied; identified users exclude anonymous sessions. + layout: { w: 12, h: 3 } + children: + - type: stat + title: Audience sessions + query: *app + sql: "{{audience_sessions}} SELECT COUNT(*)::float FROM audience_sessions" + unit: sessions + layout: { w: 3, h: 2 } + - type: stat + title: Identified audience users + query: *app + sql: "{{audience_sessions}} SELECT COUNT(DISTINCT COALESCE('id:' || user_id, 'email:' || email))::float FROM audience_sessions" + unit: users + layout: { w: 3, h: 2 } + - type: stat + title: Sessions with errors + query: *app + sql: "{{audience_sessions}} SELECT COALESCE(SUM(errored), 0)::float FROM audience_sessions" + unit: sessions + series_intent: error + layout: { w: 3, h: 2 } + - type: stat + title: Session error rate + description: Sessions with at least one browser error divided by all sessions in the selected audience. + query: *app + sql: "{{audience_sessions}} SELECT (100.0 * SUM(errored) / NULLIF(COUNT(*), 0))::float FROM audience_sessions" + unit: '%' + series_intent: error + layout: { w: 3, h: 2 } + - type: table - title: Browsers, systems and devices - description: Sessions per user-agent family; errored counts sessions with at least one browser error. - layout: { w: 12, h: 7 } + title: Browsers + description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. + layout: { x: 0, w: 4, h: 5 } + on_row_click: + set_variable: browser + navigate_to_tab: Audience columns: - - field: dimension - title: Dimension - field: name - title: Name + title: Browser - field: sessions title: Sessions column_type: number - progress: column_percent - - field: errored - title: Errored + - field: share + title: Share + unit: '%' + column_type: number + - field: affected + title: Affected description: Sessions with at least one browser error. column_type: number + - field: error_rate + title: Error rate + unit: '%' + column_type: number query: *app sql: | - WITH sessions AS ( - SELECT COALESCE(NULLIF(attributes___user_agent___original, ''), resource___user_agent___original) AS ua, attributes___session___id AS session, - MAX(CASE WHEN {{error_filter}} THEN 1 ELSE 0 END) AS errored - FROM otel_logs_and_spans WHERE {{query_ast_filters}} AND {{browser_filter}} - GROUP BY 1, 2 - ), families AS ( - SELECT session, errored, - CASE WHEN ua LIKE '%Edg/%' OR ua LIKE '%Edge/%' THEN 'Edge' WHEN ua LIKE '%OPR/%' OR ua LIKE '%Opera%' THEN 'Opera' WHEN ua LIKE '%SamsungBrowser%' THEN 'Samsung Internet' - WHEN ua LIKE '%Firefox/%' THEN 'Firefox' WHEN ua LIKE '%Chrome/%' OR ua LIKE '%CriOS/%' THEN 'Chrome' WHEN ua LIKE '%Safari/%' THEN 'Safari' ELSE 'Other' END AS browser, - CASE WHEN ua LIKE '%Windows%' THEN 'Windows' WHEN ua LIKE '%iPhone%' OR ua LIKE '%iPad%' OR ua LIKE '%iPod%' THEN 'iOS' WHEN ua LIKE '%Mac OS X%' OR ua LIKE '%Macintosh%' THEN 'macOS' - WHEN ua LIKE '%Android%' THEN 'Android' WHEN ua LIKE '%CrOS%' THEN 'ChromeOS' WHEN ua LIKE '%Linux%' OR ua LIKE '%X11%' THEN 'Linux' ELSE 'Other' END AS os, - CASE WHEN ua LIKE '%iPad%' OR (ua LIKE '%Android%' AND ua NOT LIKE '%Mobile%') THEN 'Tablet' WHEN ua LIKE '%Mobile%' OR ua LIKE '%iPhone%' THEN 'Mobile' ELSE 'Desktop' END AS device - FROM sessions WHERE ua IS NOT NULL - ), counted AS ( - SELECT 1 AS d, 'Browser' AS dimension, browser AS name, COUNT(DISTINCT session) AS n, SUM(errored) AS errored FROM families GROUP BY browser - UNION ALL SELECT 2, 'Operating system', os, COUNT(DISTINCT session), SUM(errored) FROM families GROUP BY os - UNION ALL SELECT 3, 'Device', device, COUNT(DISTINCT session), SUM(errored) FROM families GROUP BY device - ) - SELECT dimension, name, n::text AS sessions, errored::text AS errored FROM counted ORDER BY d, n DESC + {{audience_sessions}} + SELECT browser AS name, COUNT(*)::text AS sessions, + ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1)::text AS share, + SUM(errored)::text AS affected, + ROUND(100.0 * SUM(errored) / NULLIF(COUNT(*), 0), 1)::text AS error_rate + FROM audience_sessions GROUP BY browser ORDER BY COUNT(*) DESC, browser + + - type: table + title: Operating systems + description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. + layout: { x: 4, w: 4, h: 5 } + on_row_click: + set_variable: os + navigate_to_tab: Audience + columns: + - field: name + title: Operating system + - field: sessions + title: Sessions + column_type: number + - field: share + title: Share + unit: '%' + column_type: number + - field: affected + title: Affected + description: Sessions with at least one browser error. + column_type: number + - field: error_rate + title: Error rate + unit: '%' + column_type: number + query: *app + sql: | + {{audience_sessions}} + SELECT os AS name, COUNT(*)::text AS sessions, + ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1)::text AS share, + SUM(errored)::text AS affected, + ROUND(100.0 * SUM(errored) / NULLIF(COUNT(*), 0), 1)::text AS error_rate + FROM audience_sessions GROUP BY os ORDER BY COUNT(*) DESC, os + + - type: table + title: Devices + description: Select a row to filter this audience. Share is within the current filters; error rate is within each group. + layout: { x: 8, w: 4, h: 5 } + on_row_click: + set_variable: device + navigate_to_tab: Audience + columns: + - field: name + title: Device + - field: sessions + title: Sessions + column_type: number + - field: share + title: Share + unit: '%' + column_type: number + - field: affected + title: Affected + description: Sessions with at least one browser error. + column_type: number + - field: error_rate + title: Error rate + unit: '%' + column_type: number + query: *app + sql: | + {{audience_sessions}} + SELECT device AS name, COUNT(*)::text AS sessions, + ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1)::text AS share, + SUM(errored)::text AS affected, + ROUND(100.0 * SUM(errored) / NULLIF(COUNT(*), 0), 1)::text AS error_rate + FROM audience_sessions GROUP BY device ORDER BY COUNT(*) DESC, device + + - type: table + title: Identified users + description: Search all identified users in this audience by name, email or id. Shows up to 200 matches, most recently active first. + searchable: true + layout: { w: 12, h: 7 } + columns: + - field: user + title: User + link: /p/{{project_id}}/log_explorer?viz_type=sessions&query={{row.kql}}{{time_range}} + - field: sessions + title: Sessions + column_type: number + - field: affected + title: Affected sessions + column_type: number + - field: last_seen + title: Last seen + - field: kql + title: Session query + hidden: true + query: *app + sql: | + {{audience_sessions}} + SELECT COALESCE(MAX(email), MAX(full_name), MAX(user_id)) AS "user", COUNT(*)::text AS sessions, + SUM(errored)::text AS affected, MAX(last_seen)::text AS last_seen, + '{{browser_kql}}' || CASE WHEN {{audience_kql_literal}} = '' THEN '' ELSE ' and ' || {{audience_kql_literal}} END || + CASE WHEN MAX(user_id) IS NOT NULL THEN ' and attributes.user.id == "' || replace(replace(MAX(user_id), '\', '\\'), '"', '\"') || '"' + ELSE ' and attributes.user.email == "' || replace(replace(MAX(email), '\', '\\'), '"', '\"') || '"' END AS kql + FROM audience_sessions WHERE user_id IS NOT NULL OR email IS NOT NULL + GROUP BY COALESCE('id:' || user_id, 'email:' || email) + HAVING {{table_search}} = '' OR strpos(lower(COALESCE(MAX(email), '') || ' ' || COALESCE(MAX(full_name), '') || ' ' || COALESCE(MAX(user_id), '')), lower({{table_search}})) > 0 + ORDER BY 4 DESC LIMIT 200 diff --git a/test/integration/Pages/DashboardWidgetsSpec.hs b/test/integration/Pages/DashboardWidgetsSpec.hs index 475e093e5..614614cb9 100644 --- a/test/integration/Pages/DashboardWidgetsSpec.hs +++ b/test/integration/Pages/DashboardWidgetsSpec.hs @@ -45,6 +45,7 @@ import Pages.Dashboards (DashboardFilters (..)) import Pages.Dashboards qualified as Dashboards import Pages.Issues qualified as Issues import Pages.LogExplorer.Log qualified as Log +import Pages.Rum qualified as Rum import Pkg.Components.Widget qualified as Widget import Pkg.Parser (browserKql, parseQueryToAST) import Pkg.QueryCache qualified as QueryCache @@ -68,6 +69,7 @@ import Utils qualified import Web.ApiHandlers qualified as Api import Web.ApiTypes qualified as ApiTypes import Web.Auth qualified as Auth +import Web.FormUrlEncoded (urlDecodeAsForm) import Web.Routes qualified as Routes @@ -1430,7 +1432,7 @@ spec = sequential $ aroundAll withTestResources do it "rumDashboard_everyWidgetRuns_andVitalsReadHistogramBuckets" \tr -> do projectId <- createTestProject tr "RUM dashboard template" apiKey <- createTestAPIKey tr projectId "rum-dashboard-key" - let browserSpan n name attrs ua = ingestSpanReq tr $ mkSpanRequest ("e" <> T.justifyRight 31 '0' (show @Text n)) ("e" <> T.justifyRight 15 '0' (show @Text n)) Nothing name [] Nothing (mkAttr "session.id" "dash-session" : attrs) (mkResource apiKey [mkAttr "service.name" "storefront", mkAttr "user_agent.original" ua]) frozenTime + let browserSpan n name attrs ua = ingestSpanReq tr $ mkSpanRequest ("e" <> T.justifyRight 31 '0' (show @Text n)) ("e" <> T.justifyRight 15 '0' (show @Text n)) Nothing name [] Nothing (mkAttr "session.id" ("dash-session-" <> show n) : attrs) (mkResource apiKey [mkAttr "service.name" "storefront", mkAttr "user_agent.original" ua]) frozenTime browserSpan (1 :: Int) "documentLoad" [mkAttr "url.full" "https://shop.example/cart"] "Mozilla/5.0 (X11; Linux x86_64) Chrome/151" browserSpan 2 "Pageview · /orders/123" [mkAttr "url.path" "/orders/123", mkAttr "exception.type" "TypeError"] "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1" -- Three DELTA exports merge to [3,9,3,1,0] over 16 observations; the 12th lands on the @@ -1471,7 +1473,7 @@ spec = sequential $ aroundAll withTestResources do exportVital "lcp" [point (-5) (-10) [4, 0] & PMF.explicitBounds .~ [2500]] mixed <- run [vitals] vitalRow mixed "LCP" `shouldBe` Just ["LCP", "2344 ms", "rating;success-strong⇒Good", "0 – 2500", "20"] - sort [name | row <- V.toList $ table results "Browsers, systems and devices", row V.!? 0 == Just "Device", Just name <- [row V.!? 1]] `shouldBe` ["Desktop", "Mobile"] + sort [name | row <- V.toList $ table results "Devices", Just name <- [row V.!? 0]] `shouldBe` ["Desktop", "Mobile"] sort (V.toList $ V.mapMaybe (V.!? 0) $ table results "Top pages") `shouldBe` ["/cart", "/orders/:id"] -- Each page row's hidden Explorer query finds the events it counted, and the rendered -- link carries it with the dashboard's window while the column itself stays off screen. @@ -1490,6 +1492,58 @@ spec = sequential $ aroundAll withTestResources do map (\ps -> (lookup "since" ps, lookup "query" ps)) (linkParams ("/p/" <> projectId.toText <> "/log_explorer") html) `shouldBe` [(Just "7D", row V.!? 3) | row <- V.toList $ table results "Top pages"] + it "audience_countsEachSessionOnce_keepsUnknown_andFiltersCohorts" \tr -> do + pid <- createTestProject tr "RUM audience" + apiKey <- createTestAPIKey tr pid "audience-key" + let emit n session ua attrs = ingestSpanReq tr $ mkSpanRequest ("a" <> T.justifyRight 31 '0' (show @Text n)) ("a" <> T.justifyRight 15 '0' (show @Text n)) Nothing "documentLoad" [] Nothing (mkAttr "session.id" session : attrs) (mkResource apiKey $ mkAttr "service.name" "storefront" : [mkAttr "user_agent.original" agent | agent <- toList ua]) frozenTime + emit (1 :: Int) "safari" (Just "iPhone Mobile Safari/604") [mkAttr "user.id" "alice", mkAttr "user.email" "alice@example.com"] + emit 2 "safari" (Just "iPhone Mobile Safari/604") [mkAttr "user.id" "alice", mkAttr "exception.type" "TypeError"] + emit 3 "chrome" (Just "Windows Chrome/151 Safari/537") [mkAttr "user.id" "bob"] + emit 4 "unknown" Nothing [] + emit 5 "" (Just "Windows Chrome/151") [mkAttr "exception.type" "TypeError"] + rum <- maybe (fail "missing rum.yaml") pure . find (\d -> d.file == Just "rum.yaml") =<< DashboardModel.readDashboardsFromDisk "static/public/dashboards" + let from = toText $ iso8601Show $ addUTCTime (-3600) frozenTime + to = toText $ iso8601Show $ addUTCTime 3600 frozenTime + readTable params title = do + w <- maybe (fail $ "missing " <> toString title) pure $ find ((== Just title) . (.title)) (DashboardModel.allWidgets rum) + result <- runQueryEffect tr $ Charts.queryMetrics Nothing (Just Charts.DTText) (Just pid) w.query w.sql Nothing (Just from) (Just to) Nothing Nothing params + result.error `shouldBe` Nothing + pure result.dataText + browsers <- readTable [] "Browsers" + sort (map (V.toList . V.take 5) $ V.toList browsers) `shouldBe` [["Chrome", "1", "33.3", "0", "0.0"], ["Safari", "1", "33.3", "1", "100.0"], ["Unknown", "1", "33.3", "0", "0.0"]] + filtered <- readTable [("var-browser", Just "Safari")] "Devices" + map (V.toList . V.take 5) (V.toList filtered) `shouldBe` [["Mobile", "1", "100.0", "1", "100.0"]] + users <- readTable [("var-errors", Just "true")] "Identified users" + map (V.toList . V.take 3) (V.toList users) `shouldBe` [["alice@example.com", "1", "1"]] + forM_ ([([("var-browser", Just "Safari")], 2), ([("var-os", Just "iOS"), ("var-device", Just "Mobile")], 2), ([("var-browser", Just "Unknown")], 1), ([("var-errors", Just "true")], 2)] :: [([(Text, Maybe Text)], Int)]) \(params, expected) -> do + headers <- runAuthHandler tr $ Dashboards.rumRedirectH pid ["sessions"] params + let location = case lookupResponseHeader @"Location" headers of Header url -> url; _ -> "" + query = lookup "query" =<< listToMaybe (linkParams ("/p/" <> pid.toText <> "/log_explorer") ("href=\"" <> location <> "\"")) + (_, events) <- testServant tr $ Log.logExplorerDataH pid def{Log.query = query, Log.from = Just from, Log.to = Just to} + (events.error, events.queryResultCount) `shouldBe` (Nothing, expected) + readTable [("var-app", Just "missing-app")] "Browsers" `shouldReturn` V.empty + readTable [("environment", Just "prod")] "Browsers" `shouldReturn` V.empty + found <- readTable [("table-search", Just "ALICE")] "Identified users" + map (V.toList . V.take 3) (V.toList found) `shouldBe` [["alice@example.com", "1", "1"]] + readTable [("table-search", Just "missing-user")] "Identified users" `shouldReturn` V.empty + + it "audienceSegments_persistFilters_keepTheWindow_andStayProjectScoped" \tr -> do + pid <- createTestProject tr "RUM segments" + otherPid <- createTestProject tr "Other RUM segments" + let params = [("var-app", Just "storefront"), ("var-browser", Just "Safari"), ("var-device", Just "Mobile"), ("since", Just "7D"), ("tab", Just "errors")] + root = "/p/" <> pid.toText <> "/rum" + render = toStrict . renderText + form <- either (fail . toString) pure $ urlDecodeAsForm @Rum.SegmentForm "name=Mobile+Safari&app=storefront&browser=Safari&device=Mobile&os=&errored=false" + (_, saved) <- testServant tr $ Rum.segmentsPostH pid params form + let links = linkParams root (render saved) + links `shouldSatisfy` any (\ps -> lookup "var-browser" ps == Just "Safari" && lookup "since" ps == Just "7D" && lookup "tab" ps == Just "errors") + (_, reloaded) <- testServant tr $ Rum.segmentsGetH pid [] + render reloaded `shouldSatisfy` T.isInfixOf ">Mobile Safari" + (_, other) <- testServant tr $ Rum.segmentsGetH otherPid [] + render other `shouldNotSatisfy` T.isInfixOf ">Mobile Safari" + (_, deleted) <- testServant tr $ Rum.segmentsDeleteH pid "Mobile Safari" [] + render deleted `shouldNotSatisfy` T.isInfixOf ">Mobile Safari" + -- /rum and its older /rum/dashboard open the dashboard; /rum/sessions the Explorer. Links from -- emails and alerts carry a window, which both must keep. it "rumRedirects_openTheDashboardOrSessions_andKeepTheWindow" \tr -> do From 638b4bee53386b50215c548ae8ca39bda231465e Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 15:33:43 +0100 Subject: [PATCH 3/7] Declare form decoding dependency for integration tests --- monoscope.cabal | 1 + package.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/monoscope.cabal b/monoscope.cabal index 55699a44b..11697fd0f 100644 --- a/monoscope.cabal +++ b/monoscope.cabal @@ -897,6 +897,7 @@ test-suite integration-tests , hs-opentelemetry-instrumentation-auto , hs-opentelemetry-instrumentation-hasql , hspec + , http-api-data , http-client , http-client-tls , http-types diff --git a/package.yaml b/package.yaml index 420c91a9f..dd2281e47 100644 --- a/package.yaml +++ b/package.yaml @@ -296,6 +296,7 @@ tests: - unix - lucid - http-types + - http-api-data - wai - wai-extra - unordered-containers From 4359600a06f595ea552af85cfbe53f1c4ecdb8b9 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 15:40:41 +0100 Subject: [PATCH 4/7] Check expanded RUM templates for query scoping --- test/unit/Models/DashboardTemplatesSpec.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/Models/DashboardTemplatesSpec.hs b/test/unit/Models/DashboardTemplatesSpec.hs index ef560515a..6ec319ea5 100644 --- a/test/unit/Models/DashboardTemplatesSpec.hs +++ b/test/unit/Models/DashboardTemplatesSpec.hs @@ -6,7 +6,7 @@ import Data.Text qualified as T import Models.Projects.Dashboards qualified as Dashboards import Models.Projects.GitSync qualified as GitSync import Pkg.Components.Widget qualified as Widget -import Pkg.Parser (browserKql, parseQueryToAST) +import Pkg.Parser (BinDensity (BarDensity), browserKql, fillQueryTemplate, fixedUTCTime, parseQueryToAST, variablePresets) import Relude import System.Directory (listDirectory) import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe, shouldSatisfy) @@ -76,8 +76,9 @@ spec = describe "dashboard templates" do Nothing -> expectationFailure "missing template: rum.yaml" Just d -> do let sqlWidgets = [w | w <- Dashboards.allWidgets d, isJust w.sql] + expand = fillQueryTemplate $ variablePresets BarDensity "project" Nothing Nothing [] fixedUTCTime sqlWidgets `shouldSatisfy` (not . null) - forM_ sqlWidgets \w -> (w.title, any (T.isInfixOf "{{query_ast_filters}}") w.sql, any (T.isInfixOf "{{var-app}}") w.query) `shouldBe` (w.title, True, True) + forM_ sqlWidgets \w -> (w.title, any (T.isInfixOf "{{query_ast_filters}}" . expand) w.sql, any (T.isInfixOf "{{var-app}}") w.query) `shouldBe` (w.title, True, True) it "endpoint latency summaries average percentile buckets and declare milliseconds" $ withEndpointTemplate \d -> do let flatten w = w : maybe [] (concatMap flatten) w.children From 06a94aece6142744bb885b1f10f8ce0f3e224e8c Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Sun, 25 Oct 2026 22:56:38 +0100 Subject: [PATCH 5/7] Supply required summaries in RUM browser fixtures --- e2e/tests/rum-audience.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/tests/rum-audience.spec.ts b/e2e/tests/rum-audience.spec.ts index ef8dd106c..71698feb9 100644 --- a/e2e/tests/rum-audience.spec.ts +++ b/e2e/tests/rum-audience.spec.ts @@ -5,12 +5,12 @@ test("audience groups filter sessions, search users, and persist named segments" const app = `e2e-rum-audience-${Date.now()}`; const segment = `${app} Mobile Safari`; const cleanup = () => sql(`DELETE FROM otel_logs_and_spans WHERE project_id='${DEMO_PROJECT}' AND resource___service___name='${app}'; DELETE FROM projects.rum_segments WHERE project_id='${DEMO_PROJECT}' AND name='${segment}';`); - sql(`INSERT INTO otel_logs_and_spans (project_id,name,timestamp,start_time,end_time,resource___service___name,resource___user_agent___original,attributes___session___id,attributes___user___id,attributes___user___email,attributes___exception___type) + sql(`INSERT INTO otel_logs_and_spans (project_id,summary,name,timestamp,start_time,end_time,resource___service___name,resource___user_agent___original,attributes___session___id,attributes___user___id,attributes___user___email,attributes___exception___type) VALUES - ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com',NULL), - ('${DEMO_PROJECT}','browser error',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com','TypeError'), - ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}','Windows Chrome/151 Safari/537','${app}-bob','bob','bob@example.com',NULL), - ('${DEMO_PROJECT}','documentLoad',now(),now(),now(),'${app}',NULL,'${app}-anonymous',NULL,NULL,NULL);`); + ('${DEMO_PROJECT}',ARRAY['RUM audience'],'documentLoad',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com',NULL), + ('${DEMO_PROJECT}',ARRAY['RUM audience'],'browser error',now(),now(),now(),'${app}','iPhone Mobile Safari/604','${app}-alice','alice','alice@example.com','TypeError'), + ('${DEMO_PROJECT}',ARRAY['RUM audience'],'documentLoad',now(),now(),now(),'${app}','Windows Chrome/151 Safari/537','${app}-bob','bob','bob@example.com',NULL), + ('${DEMO_PROJECT}',ARRAY['RUM audience'],'documentLoad',now(),now(),now(),'${app}',NULL,'${app}-anonymous',NULL,NULL,NULL);`); try { const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); From 4ee74fefa974d56d4cab06b94637998f1f25a11c Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 12:25:55 +0100 Subject: [PATCH 6/7] Prepare shared npm dependencies before parallel CI lanes --- ci/checks.tsv | 2 ++ scripts/ci/ci.sh | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/ci/checks.tsv b/ci/checks.tsv index 272160e71..306948386 100644 --- a/ci/checks.tsv +++ b/ci/checks.tsv @@ -16,6 +16,8 @@ # # Narrow `inputs` only where it is provably sound. A too-wide set costs a rerun; # a too-narrow one ships an untested change. +# Parallel integration, UI, and e2e lanes share dependency installs prepared by +# the runner before launch; each check still declares its own source inputs. frontend node @fe @uisrc build ghc @hs @fe doctests ghc @hs @fe diff --git a/scripts/ci/ci.sh b/scripts/ci/ci.sh index 7ec498228..e120449d4 100755 --- a/scripts/ci/ci.sh +++ b/scripts/ci/ci.sh @@ -332,9 +332,17 @@ CABAL_OPTS="--ghc-options=-O0 -j$CI_CORES +RTS -A64m -n2m -RTS" CABAL_FLAGS='-f-devtest' prepare_browser() { + [ "${CI_BROWSER_PREPARED:-}" != true ] || return 0 (cd e2e && npm ci --prefer-offline --no-audit && \ { [ -e /usr/local/share/monoscope-playwright-deps ] || npx playwright install-deps chromium; } && \ npx playwright install chromium) + export CI_BROWSER_PREPARED=true +} + +prepare_web_deps() { + [ "${CI_WEB_DEPS_PREPARED:-}" != true ] || return 0 + (cd web-components && npm ci --prefer-offline --no-audit) + export CI_WEB_DEPS_PREPARED=true } run_body() { # @@ -384,7 +392,7 @@ run_body() { # ;; release-tests) node --test scripts/ci/release-test.mjs ;; hlint) hlint -j src/ shared/src cli ;; - ui-tests) (cd web-components && npm ci --prefer-offline --no-audit && npm test) ;; + ui-tests) prepare_web_deps; (cd web-components && npm test) ;; # Drives the real server in a real browser. scripts/e2e.sh starts that server itself on # 8081 against a throwaway database, so this only has to supply the binary and chromium. e2e) @@ -410,7 +418,7 @@ run_integration() { # reached under CI_ALLOW_DEGRADED — cmd_run refuses this check otherwise. case " ${CAPS:-} " in *" tf-real "*) ;; *) unset TIMEFUSION_PG_TEST_URL ;; esac export USE_EXTERNAL_DB=true LOG_LEVEL=${LOG_LEVEL:-warn} - (cd web-components && npm ci --prefer-offline --no-audit) + prepare_web_deps prepare_browser make build-chart-cli [ -n "${CI_INTEGRATION_BIN:-}" ] || cabal build integration-tests -j $CABAL_FLAGS "$CABAL_OPTS" @@ -484,6 +492,11 @@ stop_early() { [ "$1" -ne 0 ] && [ "${CI_KEEP_GOING:-}" != true ]; } # Background lanes live in $BG_DIR as .{pid,log,result}. start_background() { # + # npm ci removes node_modules: finish installs before any parallel consumer. + case "$1" in + integration-tests) prepare_web_deps; prepare_browser ;; + e2e) prepare_browser ;; + esac CI_ATTEST_OUT="$BG_DIR/$1.result" "$0" run "$1" > "$BG_DIR/$1.log" 2>&1 & echo $! > "$BG_DIR/$1.pid" } @@ -1013,6 +1026,21 @@ cmd_selftest() { assert "background failure propagates" 1 "$body_rc" assert "background diagnostics retained" yes "$([ -f "$BG_DIR/failing.log" ] && echo yes || echo no)" rm -rf "$BG_DIR" + BG_DIR=$(mktemp -d -t ci-selftest-browser.XXXXXX) + printf '#!/bin/sh\necho install >> "%s/calls"\n' "$BG_DIR" > "$BG_DIR/npm" + printf '#!/bin/sh\nexit 0\n' > "$BG_DIR/npx" + chmod +x "$BG_DIR/npm" "$BG_DIR/npx" + ( + export PATH="$BG_DIR:$PATH" + unset CI_BROWSER_PREPARED CI_WEB_DEPS_PREPARED + prepare_browser + prepare_web_deps + prepare_browser & browser_one=$! + prepare_web_deps & browser_two=$! + wait "$browser_one" "$browser_two" + ) + assert "parallel lanes install each dependency tree once" 2 "$(wc -l < "$BG_DIR/calls" | tr -d ' ')" + rm -rf "$BG_DIR" # kafka-topics.sh under macOS /bin/bash 3.2 with no SASL user: an empty array under # `set -u` killed it silently (2026-10-20). A stub rpk records what it was asked to do. # Only bash < 4.4 reproduces the bug, so on Linux CI this checks the happy path alone. From 4d5201c826513ca1107468c2c4db3785f57aa016 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Mon, 26 Oct 2026 12:38:29 +0100 Subject: [PATCH 7/7] Target the segment disclosure explicitly in browser coverage --- e2e/tests/rum-audience.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/rum-audience.spec.ts b/e2e/tests/rum-audience.spec.ts index 71698feb9..6f825e694 100644 --- a/e2e/tests/rum-audience.spec.ts +++ b/e2e/tests/rum-audience.spec.ts @@ -45,7 +45,7 @@ test("audience groups filter sessions, search users, and persist named segments" await safari.focus(); await safari.press("Enter"); await expect(page).toHaveURL(/var-browser=Safari/); - await toolbar.getByText("Save segment", { exact: true }).click(); + await toolbar.locator("summary").filter({ hasText: "Save segment" }).click(); await toolbar.getByLabel("Segment name").fill(segment); await toolbar.getByRole("button", { name: "Save segment", exact: true }).click(); await expect(toolbar.getByRole("link", { name: segment, exact: true })).toBeVisible();