diff --git a/apps/docs/docs/guides/document-analytics.mdx b/apps/docs/docs/guides/document-analytics.mdx index 0a0c273..f09b876 100644 --- a/apps/docs/docs/guides/document-analytics.mdx +++ b/apps/docs/docs/guides/document-analytics.mdx @@ -1,23 +1,29 @@ --- title: Document analytics -description: Map the nearest document root and types to show reports automatically filtered to the selected published route. Document reports do not require global Analytics-section access. +description: Map document roots and types to show reports automatically filtered to the selected published route. Document reports do not require global Analytics-section access. --- Document mappings are optional. A connection without a mapping remains available in the global Analytics section but does not add a document workspace report. Document reports do not require global Analytics-section access. ## Map an Umbraco site root -For each site that needs page-level reports, choose the root document in the connection's **Page analytics** settings. A document uses the connection from its nearest mapped ancestor; it does not use a connection from merely any ancestor root. A mapped document report is automatically filtered to its selected published route. +For each site that needs page-level reports, choose the root document in the connection's **Page analytics** settings. A document uses the connections from its nearest mapped ancestor; it does not use connections from merely any ancestor root. A mapped document report is automatically filtered to its selected published route. Then either enable all document types below that root or choose the specific document types that should show the Analytics workspace view. +You can map more than one connection to the same root, for example when the site reports to both Vercel and Plausible. When more than one mapped connection supports the document type, the workspace shows a provider selector. Its selection is remembered in that browser for the mapped root, so choosing a provider for one site does not change another site's report. + +## Choose the path scope + +Document analytics initially reports only the selected document's published path. Enable **Include child paths** in the workspace header to report on that path and every path below it. This is useful for section or landing pages; leave it disabled to inspect only the selected page. + ## When the workspace appears A document shows its Analytics workspace view only when all of these conditions are met: - It is published and has a published route. -- Its nearest configured document root must specifically resolve to a connection, not merely any ancestor root. -- Its document type is enabled for that connection. +- Its nearest configured document root must specifically resolve to one or more connections, not merely any ancestor root. +- Its document type is enabled for at least one of those connections. - The current user can access the Content section and browse that document. This lets an editor inspect the page they are working on without first finding it in a global report. @@ -35,6 +41,8 @@ Imagine one Umbraco installation with two site roots: If a nested root is mapped too, it wins for documents below it because it is the nearest mapped ancestor. +You can also map both a Vercel project and a Plausible site to `Brand A`. Editors then choose the provider they need from the document workspace header. + ## Permissions Global and document analytics are intentionally separate: diff --git a/apps/docs/docs/reference/configuration.mdx b/apps/docs/docs/reference/configuration.mdx index 056aad1..814f00b 100644 --- a/apps/docs/docs/reference/configuration.mdx +++ b/apps/docs/docs/reference/configuration.mdx @@ -56,7 +56,7 @@ For Plausible, use `"Provider": "Plausible"` and set `SiteId` instead of `Projec | `DefaultRangeDays` | `30` | Initial reporting range. Valid values are 1–730. | | `CacheDuration` | `00:05:00` | Per-instance in-memory cache duration. Valid from zero to one hour. | | `Connections` | `[]` | Provider connection definitions. The first becomes the initial default. | -| `ConnectionAccessTokens` | Empty | Optional secret dictionary keyed by a connection GUID. | +| `ConnectionAccessTokens` | Empty | Optional connection-specific credential overrides, keyed by connection GUID. An override takes precedence over the shared provider credential. Set one with `WebAnalytics__ConnectionAccessTokens__{connection-guid}`. | | `Providers:Vercel:AccessToken` | Empty | Shared Vercel access token. | | `Providers:Plausible:AccessToken` | Empty | Shared Plausible Stats API key. | | `Providers:Plausible:BaseUrl` | `https://plausible.io/` | Public base URL of the Plausible Cloud or self-hosted instance. It must expose `/api/v2/query`. | @@ -75,7 +75,7 @@ For Plausible, use `"Provider": "Plausible"` and set `SiteId` instead of `Projec | `EnableEvents` | All | Whether to show provider-supported custom events. | | `EnableFlags` | Vercel | Whether to show provider-supported feature flags. | | `MockScenario` | Development only | Selects deterministic mock report data when mock connections are enabled: `Complete`, `Utm`, `Flags`, or `Events`. | -| `DocumentRootKeys` | All | Root document GUIDs mapped to this connection. | +| `DocumentRootKeys` | All | Root document GUIDs mapped to this connection. More than one connection may map the same root; editors select the provider in the document workspace. | | `EnableAllDocumentTypes` | All | Enables document analytics for every type below a mapped root. | | `EnabledDocumentTypeKeys` | All | Restricts document analytics to specific document-type GUIDs. | | `EnabledDocumentTypes` | All | Document-type aliases for configuration-only bootstrapping. | diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts index 18eacf8..0ca4996 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -75,6 +75,8 @@ describe("analytics presentation components", () => { element.siteUrl = "https://example.com"; element.route = { connection: "11111111-1111-1111-1111-111111111111", + displayName: "Main", + documentRoot: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", provider: "Vercel", capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, globalEventPropertyFiltering: true, flags: true, breakdownOrdering: false }, culture: "en-US", @@ -97,6 +99,8 @@ describe("analytics presentation components", () => { expect(favicon?.height).toBe(20); expect(favicon?.getAttribute("referrerpolicy")).toBe("no-referrer"); expect(element.shadowRoot?.querySelector(".site-mark uui-icon")).toBeNull(); + expect(element.shadowRoot?.querySelector(".site-context .child-paths-toggle")?.textContent).toContain("Include child paths"); + expect(element.shadowRoot?.querySelector(".controls .child-paths-toggle")).toBeNull(); favicon?.dispatchEvent(new Event("error")); await element.updateComplete; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts index 00fd634..c90a84a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts @@ -1,7 +1,7 @@ import { LitElement, css, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; -import type { UUISelectElement } from "@umbraco-cms/backoffice/external/uui"; +import type { UUISelectElement, UUIToggleElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsConnectionSummary, AnalyticsDocumentRoute } from "../api/types.gen.js"; import type { AnalyticsDateRange, DatePreset } from "./date-range.js"; import { googleFaviconUrl } from "./favicon.js"; @@ -16,6 +16,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme @property() preset: DatePreset = 30; @property() siteUrl?: string; @property({ type: Boolean }) documentScoped = false; + @property({ type: Boolean }) includeChildPaths = false; #failedFaviconHostname?: string; #connection(): AnalyticsConnectionSummary | undefined { @@ -49,6 +50,14 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme })); } + #onIncludeChildPathsChange(event: Event): void { + this.dispatchEvent(new CustomEvent("include-child-paths-change", { + bubbles: true, + composed: true, + detail: { includeChildPaths: (event.target as UUIToggleElement).checked }, + })); + } + render() { const connection = this.#connection(); const showDateRange = connection?.isConfigured !== false; @@ -81,9 +90,12 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme ` : siteLabel ? html` ${siteLabel} ` : ""} + ${this.documentScoped ? html` + Include child paths + ` : ""}
- ${!this.documentScoped && this.connections.length > 1 ? html` + ${this.connections.length > 1 ? html` ` : ""} ${showDateRange ? html`` : ""} @@ -121,6 +133,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme min-inline-size: 11rem; } .project-select:hover { --uui-select-background-color: var(--uui-color-surface-alt); } + .child-paths-toggle { margin-inline-start: var(--uui-size-space-2); white-space: nowrap; } .warnings { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-5); } .warnings:empty { display: none; } .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } @@ -133,7 +146,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme header { align-items: stretch; } .site-context { flex: 1 1 100%; } .controls { align-items: stretch; inline-size: 100%; margin-inline-start: 0; } - .project-select, web-analytics-date-range-picker { box-sizing: border-box; flex: 1 1 100%; inline-size: 100%; max-inline-size: none; } + .project-select, .child-paths-toggle, web-analytics-date-range-picker { box-sizing: border-box; flex: 1 1 100%; inline-size: 100%; max-inline-size: none; } } `]; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts index fb77400..0c8c399 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts @@ -93,7 +93,29 @@ describe("AnalyticsDashboardController", () => { await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); expect(controller.state.provider).toBe("Plausible"); - expect(controller.state.connections).toEqual([]); + expect(controller.state.connections).toHaveLength(1); + expect(controller.state.connections[0]?.provider).toBe("Plausible"); + }); + + it("restores and updates the root-scoped document connection preference", async () => { + const api = dashboardApi(); + const primary = route("/case", "en-US"); + const secondary = { ...primary, connection: "22222222-2222-2222-2222-222222222222", displayName: "Plausible", provider: "Plausible" as const }; + api.documentRoutes.mockResolvedValue(ok([primary, secondary])); + const setStoredDocumentConnection = vi.fn(); + const controller = new AnalyticsDashboardController(vi.fn(), api, { + ...environment(), + getStoredDocumentConnection: () => secondary.connection, + setStoredDocumentConnection, + }); + + controller.connect("document-id", "en-US"); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + + expect(controller.state.connection).toBe(secondary.connection); + expect(controller.state.connections.map(({ displayName }) => displayName)).toEqual(["Main", "Plausible"]); + controller.setConnection(primary.connection); + expect(setStoredDocumentConnection).toHaveBeenCalledWith(primary.documentRoot, primary.connection); }); it("does not restore a breakdown after its dialog closes during a request", async () => { @@ -980,7 +1002,7 @@ function dashboardApi() { } function route(path: string, culture: string): AnalyticsDocumentRoute { - return { connection: "11111111-1111-1111-1111-111111111111", provider: "Vercel", capabilities: fullCapabilities, culture, hostname: "example.com", path, url: `https://example.com${path}`, isCurrent: true, warnings: [] }; + return { connection: "11111111-1111-1111-1111-111111111111", displayName: "Main", documentRoot: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", provider: "Vercel", capabilities: fullCapabilities, culture, hostname: "example.com", path, url: `https://example.com${path}`, isCurrent: true, warnings: [] }; } function environment(initialUrl = "https://cms.example.com/umbraco/section/analytics"): DashboardEnvironment { @@ -990,6 +1012,8 @@ function environment(initialUrl = "https://cms.example.com/umbraco/section/analy replaceUrl: (next) => { url = new URL(next); }, getStoredConnection: () => null, setStoredConnection: vi.fn(), + getStoredDocumentConnection: () => null, + setStoredDocumentConnection: vi.fn(), languages: ["en-US"], }; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts index 4c73b65..9640759 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -33,7 +33,7 @@ import { detectUtmCapability, type UtmCapability } from "./utm-capability.js"; import { errorState, idleState, loadingState, successState, type AsyncState } from "./async-state.js"; import { normalizeDashboardSelection, supportsDimension, unavailableCapabilities } from "./dashboard-capabilities.js"; -type ReportScope = { documentId?: string; culture?: string; path?: string }; +type ReportScope = { documentId?: string; culture?: string; path?: string; includeChildPaths?: boolean }; type ReportFilterQuery = Pick; export type ExpandedBreakdown = { dimension: AnalyticsDimension; @@ -61,6 +61,7 @@ export type DashboardState = { filters: AnalyticsFilter[]; flagFilter?: AnalyticsFlagFilter; eventFilter?: AnalyticsEventFilter; + includeChildPaths: boolean; configurationError?: string; setupRequired?: boolean; utmCapability: UtmCapability; @@ -72,6 +73,8 @@ export type DashboardEnvironment = { replaceUrl: (url: URL) => void; getStoredConnection: () => string | null; setStoredConnection: (connection: string) => void; + getStoredDocumentConnection: (root: string) => string | null; + setStoredDocumentConnection: (root: string, connection: string) => void; languages: ReadonlyArray; }; @@ -80,6 +83,8 @@ const defaultEnvironment = (): DashboardEnvironment => ({ replaceUrl: (url) => window.history.replaceState(window.history.state, "", url), getStoredConnection: () => localStorage.getItem("thebuilder-web-analytics:connection"), setStoredConnection: (connection) => localStorage.setItem("thebuilder-web-analytics:connection", connection), + getStoredDocumentConnection: (root) => localStorage.getItem(`thebuilder-web-analytics:document-connection:${root}`), + setStoredDocumentConnection: (root, connection) => localStorage.setItem(`thebuilder-web-analytics:document-connection:${root}`, connection), languages: navigator.languages, }); @@ -98,6 +103,7 @@ export class AnalyticsDashboardController { acquisitionView: "referrers", utmDimension: "UtmSource", filters: [], + includeChildPaths: false, utmCapability: "unknown", }; @@ -114,6 +120,7 @@ export class AnalyticsDashboardController { #scopeKey?: string; #urlRestored = false; #hasUrlDateState = false; + #documentRoutes: AnalyticsDocumentRoute[] = []; constructor(notify: () => void, api: DashboardApi = dashboardApi, environment = defaultEnvironment()) { this.#notify = notify; @@ -144,6 +151,7 @@ export class AnalyticsDashboardController { this.#scopeKey = key; this.#documentId = documentId; this.#culture = culture; + this.#documentRoutes = []; this.#cancelRequests(); this.#set({ route: undefined, @@ -243,7 +251,12 @@ export class AnalyticsDashboardController { setConnection(connection: string): void { this.#utmRequest.cancel(); - this.#environment.setStoredConnection(connection); + const documentRoute = this.#documentId + ? activeDocumentRoute(this.#documentRoutes, this.#culture, connection) + : undefined; + if (this.#documentId && !documentRoute) return; + if (documentRoute) this.#environment.setStoredDocumentConnection(documentRoute.documentRoot, connection); + else this.#environment.setStoredConnection(connection); // A report from one project must never remain visible while another project's // request is in flight. Other refreshes retain their previous value, but a // connection change crosses the data boundary and starts with empty state. @@ -252,6 +265,7 @@ export class AnalyticsDashboardController { const selection = normalizeDashboardSelection(this.state, capabilities); this.#changeReportScope({ connection, + route: documentRoute ?? this.state.route, provider: selectedConnection?.provider, capabilities, ...selection, @@ -267,6 +281,11 @@ export class AnalyticsDashboardController { this.#changeReportScope({ preset, range }); } + setIncludeChildPaths(includeChildPaths: boolean): void { + if (this.state.includeChildPaths === includeChildPaths) return; + this.#changeReportScope({ includeChildPaths }); + } + setMetric(metric: DashboardMetric): void { if (this.state.metric === metric) return; this.#set({ metric }); @@ -409,13 +428,30 @@ export class AnalyticsDashboardController { })); if (!this.#initializationSucceeded(result)) return false; const { data, error } = result.value; - const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; + const routes = !error ? data ?? [] : []; + const root = routes[0]?.documentRoot; + const requested = routes.some((route) => route.connection === this.state.connection) ? this.state.connection : undefined; + const stored = root ? this.#environment.getStoredDocumentConnection(root) : undefined; + const storedValid = routes.some((route) => route.connection === stored) ? stored : undefined; + const connection = requested ?? storedValid ?? routes[0]?.connection; + const route = connection ? activeDocumentRoute(routes, this.#culture, connection) : undefined; if (!route) { this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); return false; } + this.#documentRoutes = routes; + const connections = Array.from(new Map(routes.map((candidate) => [candidate.connection, { + key: candidate.connection, + displayName: candidate.displayName, + provider: candidate.provider, + capabilities: candidate.capabilities, + isDefault: false, + isConfigured: true, + baseUrl: undefined, + warnings: candidate.warnings, + }])).values()); const selection = normalizeDashboardSelection(this.state, route.capabilities); - this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); + this.#set({ connections, route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); return true; } @@ -579,7 +615,12 @@ export class AnalyticsDashboardController { #scope(): ReportScope { return this.#documentId && this.state.route - ? { documentId: this.#documentId, culture: this.state.route.culture, path: this.state.route.path } + ? { + documentId: this.#documentId, + culture: this.state.route.culture, + path: this.state.route.path, + includeChildPaths: this.state.includeChildPaths, + } : {}; } @@ -628,6 +669,7 @@ export class AnalyticsDashboardController { filters: parsed.filters, flagFilter: parsed.flagFilter, eventFilter: parsed.eventFilter, + includeChildPaths: parsed.includeChildPaths, }; if (parsed.range) { patch.range = parsed.range; @@ -652,6 +694,7 @@ export class AnalyticsDashboardController { filters: this.state.filters, flagFilter: this.state.flagFilter, eventFilter: this.state.eventFilter, + includeChildPaths: this.state.includeChildPaths, })); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts index 0d62b6e..b12d425 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -123,7 +123,9 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .preset=${state.preset} .siteUrl=${this.#controller.linkBaseUrl()} .documentScoped=${Boolean(this.documentId)} + .includeChildPaths=${state.includeChildPaths} @connection-change=${(event: CustomEvent<{ connection: string }>) => this.#controller.setConnection(event.detail.connection)} + @include-child-paths-change=${(event: CustomEvent<{ includeChildPaths: boolean }>) => this.#controller.setIncludeChildPaths(event.detail.includeChildPaths)} @analytics-date-range-change=${(event: CustomEvent) => this.#controller.setDateRange(event.detail.preset, event.detail.range)}> `; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts index 073c61c..d28e16d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts @@ -4,7 +4,7 @@ import { parseDashboardUrlState, writeDashboardUrlState } from "./dashboard-url- describe("analytics dashboard URL state", () => { it("parses shareable report state and ignores malformed filters", () => { const state = parseDashboardUrlState(new URLSearchParams( - "connection=main&range=30&from=2026-06-17T00%3A00%3A00Z&to=2026-07-16T00%3A00%3A00Z&tz=UTC&metric=pageViews&audience=BrowserName&utm=UtmCampaign&filter=Country%3ADK&filter=RequestPath%3A%2Fnews%3Aarchive&filter=EventName%3ASignup&filter=Nope%3Ax&filter=Country%3AUS&filterFlagKey=new-pricing-page&filterFlagValue=editorial%3Acompact", + "connection=main&range=30&from=2026-06-17T00%3A00%3A00Z&to=2026-07-16T00%3A00%3A00Z&tz=UTC&metric=pageViews&audience=BrowserName&utm=UtmCampaign&filter=Country%3ADK&filter=RequestPath%3A%2Fnews%3Aarchive&filter=EventName%3ASignup&filter=Nope%3Ax&filter=Country%3AUS&filterFlagKey=new-pricing-page&filterFlagValue=editorial%3Acompact&includeChildPaths=true", )); expect(state.connection).toBe("main"); @@ -19,6 +19,7 @@ describe("analytics dashboard URL state", () => { { dimension: "EventName", value: "Signup" }, ]); expect(state.flagFilter).toEqual({ flagKey: "new-pricing-page", value: "editorial:compact" }); + expect(state.includeChildPaths).toBe(true); }); it("writes analytics state while preserving unrelated Umbraco parameters", () => { @@ -32,6 +33,7 @@ describe("analytics dashboard URL state", () => { filters: [{ dimension: "Country", value: "DK" }], flagFilter: { flagKey: "new-pricing-page", value: "control" }, eventFilter: { eventName: "Signup completed", property: "plan", value: "Pro" }, + includeChildPaths: true, }); expect(url.searchParams.get("umbDebug")).toBe("true"); @@ -44,6 +46,7 @@ describe("analytics dashboard URL state", () => { expect(url.searchParams.get("filterEventName")).toBe("Signup completed"); expect(url.searchParams.get("filterEventProperty")).toBe("plan"); expect(url.searchParams.get("filterEventValue")).toBe("Pro"); + expect(url.searchParams.get("includeChildPaths")).toBe("true"); expect(parseDashboardUrlState(url.searchParams).eventFilter).toEqual({ eventName: "Signup completed", property: "plan", diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts index db771f0..df67cb4 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts @@ -18,6 +18,7 @@ export type DashboardUrlState = { filters: AnalyticsFilter[]; flagFilter?: AnalyticsFlagFilter; eventFilter?: AnalyticsEventFilter; + includeChildPaths: boolean; }; const DIMENSIONS = new Set([ @@ -74,6 +75,7 @@ export function parseDashboardUrlState(params: URLSearchParams): DashboardUrlSta filters, flagFilter, eventFilter, + includeChildPaths: params.get("includeChildPaths") === "true", }; } @@ -92,9 +94,9 @@ function parseUtmDimension(value: string | null): UtmDimension { : "UtmSource"; } -export function writeDashboardUrlState(url: URL, state: Required> & Pick): URL { +export function writeDashboardUrlState(url: URL, state: Required> & Partial>): URL { const params = url.searchParams; - for (const name of ["connection", "range", "from", "to", "tz", "metric", "audience", "utm", "filter", "filterFlagKey", "filterFlagValue", "filterEventName", "filterEventProperty", "filterEventValue"]) params.delete(name); + for (const name of ["connection", "range", "from", "to", "tz", "metric", "audience", "utm", "filter", "filterFlagKey", "filterFlagValue", "filterEventName", "filterEventProperty", "filterEventValue", "includeChildPaths"]) params.delete(name); if (state.connection) params.set("connection", state.connection); params.set("range", String(state.preset)); params.set("from", state.range.from); @@ -104,6 +106,7 @@ export function writeDashboardUrlState(url: URL, state: Required params.append("filter", serializeFilter(filter))); + if (state.includeChildPaths) params.set("includeChildPaths", "true"); if (state.flagFilter) { params.set("filterFlagKey", state.flagFilter.flagKey); params.set("filterFlagValue", state.flagFilter.value); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts index 34a5a26..66f2873 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts @@ -12,6 +12,8 @@ const route = (culture: string, isCurrent = false): AnalyticsDocumentRoute => ({ url: `https://www.example.com/${culture}`, isCurrent, warnings: [], + displayName: "Main", + documentRoot: "11111111-1111-1111-1111-111111111111", }); describe("active document route", () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.ts index d751702..4dc7327 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.ts @@ -3,12 +3,14 @@ import type { AnalyticsDocumentRoute } from "../api/types.gen.js"; export function activeDocumentRoute( routes: AnalyticsDocumentRoute[], culture?: string, + connection?: string, ): AnalyticsDocumentRoute | undefined { + const connectionRoutes = connection ? routes.filter((route) => route.connection === connection) : routes; if (culture) { - return routes.find((route) => route.culture.toLocaleLowerCase() === culture.toLocaleLowerCase()); + return connectionRoutes.find((route) => route.culture.toLocaleLowerCase() === culture.toLocaleLowerCase()); } - return routes.find((route) => route.isCurrent) ?? routes[0]; + return connectionRoutes.find((route) => route.isCurrent) ?? connectionRoutes[0]; } export function workspaceAnalyticsCulture( diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts index 17cb298..26c52bf 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts @@ -84,6 +84,8 @@ export type AnalyticsDocumentRoute = { url: string; isCurrent: boolean; warnings: Array; + displayName: string; + documentRoot: string; }; export type AnalyticsEventDetails = { @@ -294,6 +296,7 @@ export type BreakdownData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; @@ -333,6 +336,7 @@ export type EventsData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; @@ -373,6 +377,7 @@ export type EventDetailsData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; @@ -416,6 +421,7 @@ export type EventPropertyValuesData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; @@ -455,6 +461,7 @@ export type FlagsData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; @@ -492,6 +499,7 @@ export type SummaryData = { documentId?: string; culture?: string; path?: string; + includeChildPaths?: boolean; filter?: Array; filterFlagKey?: string; filterFlagValue?: string; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts index a9e0d66..b01b01d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts @@ -113,10 +113,10 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement return count ? `${count} selected document type${count === 1 ? "" : "s"}` : "No document workspace analytics"; } - async #copyTokenKey(): Promise { + async #copyTokenKey(settingName: string): Promise { window.clearTimeout(this._copyStatusTimer); try { - await navigator.clipboard.writeText(`WebAnalytics__ConnectionAccessTokens__${this.connection.key}`); + await navigator.clipboard.writeText(settingName); this._tokenCopyStatus = "copied"; } catch { this._tokenCopyStatus = "failed"; @@ -130,21 +130,42 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement #renderCredentialSection(connection: EditableAnalyticsConnection, descriptor: AnalyticsProviderDescriptor) { const required = !connection.hasAccessToken; + const connectionCredentialKey = `WebAnalytics__ConnectionAccessTokens__${connection.key}`; + const sharedCredentialKey = `WebAnalytics__Providers__${connection.provider}__AccessToken`; return html`
${required ? "Connection credential" : "Credential override"}${required ? "Required before testing" : connection.hasAccessTokenOverride ? "Configured on the server" : "Using shared credential"}

${required - ? `No shared ${connection.provider} ${descriptor.credential.label} was detected. Add a connection-specific credential before testing this connection.` + ? `No ${connection.provider} ${descriptor.credential.label} was detected. Configure either a shared credential for all ${connection.provider} connections or a connection-specific credential for this connection.` : `Set a connection-specific credential only when this connection cannot use the shared ${connection.provider} ${descriptor.credential.label}.`} Create a ${connection.provider} ${descriptor.credential.label}

-
- WebAnalytics__ConnectionAccessTokens__${connection.key} - ${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} -
+ ${required ? html` +
+
+ Shared credential +
+ ${sharedCredentialKey} + this.#copyTokenKey(sharedCredentialKey)}>${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} +
+
+
+ Connection-specific credential +
+ ${connectionCredentialKey} + this.#copyTokenKey(connectionCredentialKey)}>${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} +
+
+
+ ` : html` +
+ ${connectionCredentialKey} + this.#copyTokenKey(connectionCredentialKey)}>${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} +
+ `} ${this._tokenCopyStatus === "failed" ? "Could not copy the setting name. Select and copy it manually." : this._tokenCopyStatus === "copied" ? "Setting name copied." : ""} @@ -425,6 +446,8 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement .token-content p { margin-top: 0; } .token-content a { align-items: center; color: var(--uui-color-interactive); display: inline-flex; gap: var(--uui-size-space-1); margin-inline-start: var(--uui-size-space-1); } .token-content a uui-icon { font-size: 0.875em; } + .credential-settings { display: grid; gap: var(--uui-size-space-4); } + .credential-setting-label { display: block; font-size: var(--uui-type-small-size); font-weight: 700; margin-block-end: var(--uui-size-space-2); } .token-key { align-items: center; background: var(--uui-color-surface-alt); display: flex; gap: var(--uui-size-space-3); justify-content: space-between; max-inline-size: 52rem; padding: var(--uui-size-space-3); } .copy-feedback { color: var(--uui-color-positive-standalone); display: block; margin-block-start: var(--uui-size-space-2); min-block-size: 1lh; } .copy-feedback.error { color: var(--uui-color-danger-standalone); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index 8282837..f02a70e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -384,8 +384,11 @@ describe("analytics settings onboarding", () => { expect(editor).not.toBeNull(); const generatedKey = (editor as AnalyticsConnectionEditorElement).connection.key; expect(generatedKey).toMatch(/^[0-9a-f-]{36}$/i); - expect(editor?.shadowRoot?.querySelector(".token-key code")?.textContent) - .toBe(`WebAnalytics__ConnectionAccessTokens__${generatedKey}`); + expect(Array.from(editor?.shadowRoot?.querySelectorAll(".token-key code") ?? []).map((element) => element.textContent)) + .toEqual([ + "WebAnalytics__Providers__Vercel__AccessToken", + `WebAnalytics__ConnectionAccessTokens__${generatedKey}`, + ]); expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent?.trim()).toContain("Unsaved changes"); expect(dashboard.shadowRoot?.querySelector('[label="Save Web Analytics settings"]')?.hasAttribute("disabled")).toBe(false); expect(dashboard.shadowRoot?.querySelectorAll('[label="Save Web Analytics settings"]')).toHaveLength(1); @@ -467,6 +470,14 @@ describe("analytics settings onboarding", () => { "Dashboard reports", ]); expect(editor.shadowRoot?.querySelector(".token-section small")?.textContent?.trim()).toBe("Required before testing"); + expect(Array.from(editor.shadowRoot?.querySelectorAll(".credential-setting-label") ?? []).map((element) => element.textContent?.trim())).toEqual([ + "Shared credential", + "Connection-specific credential", + ]); + expect(Array.from(editor.shadowRoot?.querySelectorAll(".token-key code") ?? []).map((element) => element.textContent)).toEqual([ + "WebAnalytics__Providers__Vercel__AccessToken", + "WebAnalytics__ConnectionAccessTokens__connection-1", + ]); }); it("shows a recoverable message when the override setting name cannot be copied", async () => { @@ -480,7 +491,7 @@ describe("analytics settings onboarding", () => { await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-connection-editor")).not.toBeNull()); const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; - editor.shadowRoot?.querySelector('[label="Copy credential setting name"]')?.click(); + editor.shadowRoot?.querySelector('[label="Copy connection credential setting name"]')?.click(); await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".copy-feedback")?.textContent).toContain("Select and copy it manually")); }); diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs index 314e0e3..3d70b1a 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs @@ -37,18 +37,22 @@ internal AnalyticsConnectionRegistry( public AnalyticsConnection? Get(Guid key) => Capture().Get(key); - public AnalyticsConnection? FindNearestRoot(IEnumerable ancestorKeys) + public IReadOnlyList<(Guid RootKey, AnalyticsConnection Connection)> FindNearestRoots(IEnumerable ancestorKeys) { var snapshot = Capture(); foreach (var key in ancestorKeys) { - if (snapshot.RootOwners.TryGetValue(key, out var connectionKey)) + if (snapshot.RootOwners.TryGetValue(key, out var connectionKeys)) { - return snapshot.Connections.GetValueOrDefault(connectionKey); + return connectionKeys + .Select(connectionKey => snapshot.Connections.GetValueOrDefault(connectionKey)) + .Where(connection => connection is not null) + .Select(connection => (key, connection!)) + .ToArray(); } } - return null; + return []; } internal RegistrySnapshot Capture() @@ -80,7 +84,10 @@ private RegistrySnapshot CreateSnapshot(WebAnalyticsSettingsSnapshot settingsSna serverConfiguration.ConnectionAccessTokens.GetValueOrDefault(connection.Key.ToString())))); var roots = connections.Values .SelectMany(connection => connection.DocumentRootKeys.Select(rootKey => (rootKey, connection.Key))) - .ToDictionary(pair => pair.rootKey, pair => pair.Key); + .GroupBy(pair => pair.rootKey) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.Select(pair => pair.Key).ToArray()); return new RegistrySnapshot( settingsSnapshot.Settings, settingsSnapshot.Revision, @@ -97,7 +104,7 @@ internal sealed record RegistrySnapshot( WebAnalyticsSettings Settings, long Revision, IReadOnlyDictionary Connections, - IReadOnlyDictionary RootOwners) + IReadOnlyDictionary> RootOwners) { public AnalyticsConnection? Get(Guid key) => Connections.GetValueOrDefault(key); } diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs index c7b2e07..fcf9db8 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs @@ -59,8 +59,6 @@ private static void ValidateConnection( { if (!Guid.TryParse(value, out var key)) failures.Add($"Connection '{label}' contains invalid document root key '{value}'."); - else if (roots.TryGetValue(key, out var owner)) - failures.Add($"Document root '{key}' is assigned to both '{owner}' and '{connection.Key}'."); else roots[key] = connection.Key; } diff --git a/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs b/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs index deb89c0..48d45d7 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs @@ -6,6 +6,9 @@ namespace TheBuilder.WebAnalytics.Controllers; public sealed class AnalyticsReportFilters { + [FromQuery(Name = "includeChildPaths")] + public bool IncludeChildPaths { get; init; } + [FromQuery(Name = "filter")] public string[]? Filter { get; init; } @@ -26,7 +29,7 @@ public sealed class AnalyticsReportFilters internal bool TryParse(out ParsedAnalyticsReportFilters parsed, out string? error) { - parsed = new([], null, null); + parsed = new([], null, null, IncludeChildPaths); if (!AnalyticsFilterParser.TryParse(Filter, out var filters, out error)) return false; var hasFlagKey = !string.IsNullOrWhiteSpace(FilterFlagKey); @@ -58,7 +61,8 @@ internal bool TryParse(out ParsedAnalyticsReportFilters parsed, out string? erro parsed = new( filters, hasFlagKey ? new AnalyticsFlagFilter(FilterFlagKey!.Trim(), FilterFlagValue!) : null, - hasEventName ? new AnalyticsEventFilter(FilterEventName!.Trim(), FilterEventProperty!.Trim(), FilterEventValue!) : null); + hasEventName ? new AnalyticsEventFilter(FilterEventName!.Trim(), FilterEventProperty!.Trim(), FilterEventValue!) : null, + IncludeChildPaths); error = null; return true; } @@ -67,4 +71,5 @@ internal bool TryParse(out ParsedAnalyticsReportFilters parsed, out string? erro internal sealed record ParsedAnalyticsReportFilters( IReadOnlyList Filters, AnalyticsFlagFilter? FlagFilter, - AnalyticsEventFilter? EventFilter); + AnalyticsEventFilter? EventFilter, + bool IncludeChildPaths); diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs index d5a278f..0f6186f 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs @@ -320,7 +320,7 @@ public async Task> EventPropertyValues( { return (null, ValidationProblem(filterError!)); } - var (filters, flagFilter, eventFilter) = parsedFilters; + var (filters, flagFilter, eventFilter, includeChildPaths) = parsedFilters; if (!registry.Settings.Enabled) { return (null, WebAnalyticsProblemFactory.CreateResult( @@ -354,7 +354,7 @@ public async Task> EventPropertyValues( return (null, ValidationProblem("The selected path is not a published route for this document and connection.")); var documentCapabilityError = ValidateCapabilities(filters, flagFilter, eventFilter, reportScope, reportRequirement, selectedRoute.Capabilities); return documentCapabilityError is null - ? (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, filters, flagFilter, eventFilter), null) + ? (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, includeChildPaths, filters, flagFilter, eventFilter), null) : (null, documentCapabilityError); } diff --git a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs index 3d87f4d..e9e307c 100644 --- a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs +++ b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs @@ -77,6 +77,7 @@ public sealed record AnalyticsQuery( DateTimeOffset To, AnalyticsInterval Interval, string? RequestPath = null, + bool IncludeChildPaths = false, IReadOnlyList? Filters = null, AnalyticsFlagFilter? FlagFilter = null, AnalyticsEventFilter? EventFilter = null) @@ -87,6 +88,7 @@ internal AnalyticsQuery( DateOnly To, AnalyticsInterval Interval, string? RequestPath = null, + bool IncludeChildPaths = false, IReadOnlyList? Filters = null) : this( Connection, @@ -94,6 +96,7 @@ internal AnalyticsQuery( new DateTimeOffset(To.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), Interval, RequestPath, + IncludeChildPaths, Filters) { } @@ -173,7 +176,9 @@ public sealed record AnalyticsDocumentRoute( string Path, string Url, bool IsCurrent, - IReadOnlyList Warnings); + IReadOnlyList Warnings, + string DisplayName = "", + Guid DocumentRoot = default); public sealed record AnalyticsSettingsResponse( string PackageVersion, diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs index 5df6dad..efc9884 100644 --- a/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs @@ -46,7 +46,7 @@ public async Task> GetRoutesAsync( var content = contentService.GetById(documentId); if (content is null) return []; - var rootConnection = FindRootConnection(content); + var rootConnections = FindRootConnections(content); var published = await publishedContent.GetDocumentAsync(documentId, currentCulture, cancellationToken); if (published is null) return []; @@ -54,26 +54,29 @@ public async Task> GetRoutesAsync( foreach (var publishedRoute in published.Routes) { cancellationToken.ThrowIfCancellationRequested(); - var connection = rootConnection; - if (connection is null || - !connection.IsDocumentTypeEnabled(published.ContentTypeAlias, published.ContentTypeKey)) continue; + foreach (var (rootKey, connection) in rootConnections) + { + if (!connection.IsDocumentTypeEnabled(published.ContentTypeAlias, published.ContentTypeKey)) continue; - routes.Add(new AnalyticsDocumentRoute( - connection.Key, - connection.Provider, - connection.Capabilities, - publishedRoute.Culture, - publishedRoute.Hostname, - publishedRoute.Path, - publishedRoute.Url, - string.Equals(publishedRoute.Culture, currentCulture, StringComparison.OrdinalIgnoreCase), - [])); + routes.Add(new AnalyticsDocumentRoute( + connection.Key, + connection.Provider, + connection.Capabilities, + publishedRoute.Culture, + publishedRoute.Hostname, + publishedRoute.Path, + publishedRoute.Url, + string.Equals(publishedRoute.Culture, currentCulture, StringComparison.OrdinalIgnoreCase), + [], + connection.DisplayName, + rootKey)); + } } return routes; } - private AnalyticsConnection? FindRootConnection(IContent content) + private IReadOnlyList<(Guid RootKey, AnalyticsConnection Connection)> FindRootConnections(IContent content) { var ancestorKeys = new List(); var current = content; @@ -83,7 +86,7 @@ public async Task> GetRoutesAsync( current = current.ParentId > 0 ? contentService.GetById(current.ParentId) : null; } - return registry.FindNearestRoot(ancestorKeys); + return registry.FindNearestRoots(ancestorKeys); } } diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs index 554cece..95bc9b8 100644 --- a/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs @@ -220,7 +220,7 @@ private static string Normalize(AnalyticsQuery query) var eventFilter = query.EventFilter is null ? string.Empty : $"{EncodeCachePart(query.EventFilter.EventName)}:{EncodeCachePart(query.EventFilter.Property)}:{EncodeCachePart(query.EventFilter.Value)}"; - return $"{query.Connection:N}:{query.From.UtcTicks}:{query.To.UtcTicks}:{query.Interval}:{query.RequestPath}:{filters}:{flagFilter}:{eventFilter}"; + return $"{query.Connection:N}:{query.From.UtcTicks}:{query.To.UtcTicks}:{query.Interval}:{query.RequestPath}:{query.IncludeChildPaths}:{filters}:{flagFilter}:{eventFilter}"; } private async Task TryGetPreviousTotalsAsync( diff --git a/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs index ac99f01..f203dc8 100644 --- a/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using TheBuilder.WebAnalytics.Configuration; using TheBuilder.WebAnalytics.Models; using TheBuilder.WebAnalytics.Providers; @@ -316,7 +317,12 @@ private static List BuildFilters( var filters = new List(); if (!string.IsNullOrWhiteSpace(query.RequestPath)) { - filters.Add(new PlausibleFilter("is", "event:page", [query.RequestPath])); + var pathPattern = query.RequestPath == "/" + ? "^/" + : $"^{Regex.Escape(query.RequestPath)}(/|$)"; + filters.Add(query.IncludeChildPaths + ? new PlausibleFilter("matches", "event:page", [pathPattern]) + : new PlausibleFilter("is", "event:page", [query.RequestPath])); } foreach (var filter in query.Filters ?? []) { diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs index 6e2be7a..9b6c021 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs @@ -308,7 +308,11 @@ private async Task SendAsync( AddTeamScope(parameters, connection.Team); if (query.RequestPath is not null) { - parameters["filter"] = $"requestPath eq '{EscapeODataString(query.RequestPath)}'"; + var path = EscapeODataString(query.RequestPath); + var childPathPrefix = query.RequestPath == "/" ? "/" : $"{path}/"; + parameters["filter"] = query.IncludeChildPaths + ? $"requestPath eq '{path}' or startswith(requestPath, '{childPathPrefix}')" + : $"requestPath eq '{path}'"; } foreach (var filter in query.Filters?.Where(filter => filter.Dimension != AnalyticsDimension.EventName) ?? []) { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs index 265e202..6242443 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs @@ -88,14 +88,12 @@ public void Connection_without_mappings_is_valid_for_global_reports() } [Fact] - public void Duplicate_root_keys_fail() + public void Duplicate_root_keys_across_connections_are_allowed() { var options = CreateOptions(); options.Connections.Add(CreateConnection(OtherKey, rootKeys: ["11111111-1111-1111-1111-111111111111"])); - var result = _sut.Validate(null, options); - - Assert.Contains(result.Failures!, failure => failure.Contains("Document root")); + Assert.True(_sut.Validate(null, options).Succeeded); } [Fact] @@ -266,11 +264,12 @@ public void Registry_prefers_nearest_root_order() rootKeys: ["22222222-2222-2222-2222-222222222222"])); var registry = CreateRegistry(options); - var root = registry.FindNearestRoot([ + var roots = registry.FindNearestRoots([ Guid.Parse("22222222-2222-2222-2222-222222222222"), Guid.Parse("11111111-1111-1111-1111-111111111111")]); - Assert.Equal(OtherKey, root?.Key); + var root = Assert.Single(roots); + Assert.Equal(OtherKey, root.Connection.Key); } [Fact] diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs index b373830..42ec5bf 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs @@ -26,7 +26,7 @@ public void Enabled_analytics_does_not_require_a_connection() } [Fact] - public void Duplicate_root_mappings_across_connections_are_rejected() + public void Duplicate_root_mappings_across_connections_are_allowed() { var settings = CreateSettings(); settings.Connections[0].DocumentRootKeys = ["11111111-1111-1111-1111-111111111111"]; @@ -40,7 +40,7 @@ public void Duplicate_root_mappings_across_connections_are_rejected() var failures = WebAnalyticsSettingsValidator.Validate(settings); - Assert.Contains(failures, failure => failure.Contains("assigned to both")); + Assert.Empty(failures); } [Fact] diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs index 22de874..32af7cf 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs @@ -35,6 +35,28 @@ public async Task Root_mapping_resolves_document_route() Assert.Empty(route.Warnings); } + [Fact] + public async Task Shared_root_mapping_resolves_a_route_for_each_connection() + { + var rootKey = Guid.NewGuid(); + var documentKey = Guid.NewGuid(); + var contentService = CreateContentTree(rootKey, documentKey); + var accessor = new Mock(); + accessor.Setup(value => value.GetDocumentAsync(documentKey, "en-US", It.IsAny())) + .ReturnsAsync(CreatePublishedDocument("www.example.com", "/news", "en-US")); + var service = new AnalyticsDocumentRouteService( + contentService.Object, + accessor.Object, + CreateRegistry( + Connection("root", roots: [rootKey]), + Connection("site", roots: [rootKey]))); + + var routes = await service.GetRoutesAsync(documentKey, "en-US", CancellationToken.None); + + Assert.Equal([RootConnectionKey, SiteConnectionKey], routes.Select(route => route.Connection)); + Assert.All(routes, route => Assert.Equal(rootKey, route.DocumentRoot)); + } + [Fact] public async Task Document_without_mapped_root_has_no_routes() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs index 96236a7..e4e84be 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs @@ -38,6 +38,22 @@ public async Task Count_targets_configured_self_hosted_base_url() Assert.Equal("https://analytics.example.com/plausible/api/v2/query", handler.Request?.RequestUri?.ToString()); } + [Fact] + public async Task Count_matches_the_page_and_its_descendants_when_requested() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[42,31]}]}"""); + var client = CreateClient(handler); + var query = CreateQuery() with { RequestPath = "/news", IncludeChildPaths = true }; + + await client.CountAsync(CreateConnection(), query, CancellationToken.None); + + using var body = JsonDocument.Parse(handler.Body!); + var filter = Assert.Single(body.RootElement.GetProperty("filters").EnumerateArray()); + Assert.Equal("matches", filter[0].GetString()); + Assert.Equal("event:page", filter[1].GetString()); + Assert.Equal("^/news(/|$)", filter[2][0].GetString()); + } + [Fact] public async Task Count_applies_a_global_event_name_filter() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs index 20ae574..cb57a45 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs @@ -64,6 +64,27 @@ await client.CountAsync( Assert.DoesNotContain("teamId=", handler.Request.RequestUri.Query); } + [Fact] + public async Task Count_includes_only_descendant_paths_when_requested() + { + var handler = new RecordingHandler("""{"data":{"pageviews":42,"visitors":31}}"""); + var client = CreateClient(handler); + var connection = CreateConnection(); + var query = new AnalyticsQuery( + connection.Key, + new DateOnly(2026, 7, 1), + new DateOnly(2026, 7, 15), + AnalyticsInterval.Day, + "/news", + IncludeChildPaths: true); + + await client.CountAsync(connection, query, CancellationToken.None); + + Assert.Contains( + "filter=requestPath eq '/news' or startswith(requestPath, '/news/')", + Uri.UnescapeDataString(handler.Request!.RequestUri!.Query)); + } + [Fact] public async Task Count_rejects_missing_visitors() {