Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/docs/docs/guides/document-analytics.mdx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -81,9 +90,12 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme
` : siteLabel ? html`
<span class="site-name"><uui-icon name="icon-globe" aria-hidden="true"></uui-icon><span>${siteLabel}</span></span>
` : ""}
${this.documentScoped ? html`
<uui-toggle class="child-paths-toggle" label="Include child paths" ?checked=${this.includeChildPaths} @change=${this.#onIncludeChildPathsChange}>Include child paths</uui-toggle>
` : ""}
</div>
<div class="controls">
${!this.documentScoped && this.connections.length > 1 ? html`
${this.connections.length > 1 ? html`
<uui-select class="project-select" label="Analytics connection" .options=${this.#selectOptions()} @change=${this.#onConnectionChange}></uui-select>
` : ""}
${showDateRange ? html`<web-analytics-date-range-picker .preset=${this.preset} .range=${this.range}></web-analytics-date-range-picker>` : ""}
Expand Down Expand Up @@ -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; }
Expand All @@ -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; }
}
`];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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"],
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardReportQuery, "filter" | "filterFlagKey" | "filterFlagValue" | "filterEventName" | "filterEventProperty" | "filterEventValue">;
export type ExpandedBreakdown = {
dimension: AnalyticsDimension;
Expand Down Expand Up @@ -61,6 +61,7 @@ export type DashboardState = {
filters: AnalyticsFilter[];
flagFilter?: AnalyticsFlagFilter;
eventFilter?: AnalyticsEventFilter;
includeChildPaths: boolean;
configurationError?: string;
setupRequired?: boolean;
utmCapability: UtmCapability;
Expand All @@ -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<string>;
};

Expand All @@ -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,
});

Expand All @@ -98,6 +103,7 @@ export class AnalyticsDashboardController {
acquisitionView: "referrers",
utmDimension: "UtmSource",
filters: [],
includeChildPaths: false,
utmCapability: "unknown",
};

Expand All @@ -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;
Expand Down Expand Up @@ -144,6 +151,7 @@ export class AnalyticsDashboardController {
this.#scopeKey = key;
this.#documentId = documentId;
this.#culture = culture;
this.#documentRoutes = [];
this.#cancelRequests();
this.#set({
route: undefined,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
}
: {};
}

Expand Down Expand Up @@ -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;
Expand All @@ -652,6 +694,7 @@ export class AnalyticsDashboardController {
filters: this.state.filters,
flagFilter: this.state.flagFilter,
eventFilter: this.state.eventFilter,
includeChildPaths: this.state.includeChildPaths,
}));
}

Expand Down
Loading