From fc74214582f818b5d688ee000454395f674d2b63 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 20 Jul 2026 22:15:33 +0200 Subject: [PATCH 01/54] feat: add Plausible analytics provider --- README.md | 54 ++-- .../analytics-breakdown-grid.element.ts | 11 +- .../analytics/analytics-components.test.ts | 2 + .../analytics-dashboard.controller.test.ts | 54 +++- .../analytics-dashboard.controller.ts | 48 +++- .../analytics/analytics-dashboard.element.ts | 9 +- .../src/analytics/dashboard-report-loader.ts | 9 +- .../src/analytics/document-route.test.ts | 2 + .../src/analytics/event-dialog.element.ts | 3 +- .../src/analytics/event-table.element.ts | 6 +- .../Client/src/api/sdk.gen.ts | 22 +- .../Client/src/api/types.gen.ts | 47 +++- .../src/settings/connection-editor.element.ts | 30 +- .../settings/settings-dashboard.element.ts | 92 ++++--- .../src/settings/settings-dashboard.test.ts | 54 ++-- .../src/settings/settings-model.test.ts | 5 +- .../Client/src/settings/settings-model.ts | 8 +- .../Composers/WebAnalyticsApiComposer.cs | 25 +- .../AnalyticsConnectionRegistry.cs | 231 ++++++++++++++++ .../VercelAnalyticsConnectionRegistry.cs | 158 ----------- .../VercelAnalyticsOptionsValidator.cs | 18 -- ...yticsOptions.cs => WebAnalyticsOptions.cs} | 17 +- .../WebAnalyticsOptionsValidator.cs | 18 ++ ...icsSettings.cs => WebAnalyticsSettings.cs} | 62 +++-- ...or.cs => WebAnalyticsSettingsValidator.cs} | 38 ++- .../Controllers/WebAnalyticsApiController.cs | 28 +- .../WebAnalyticsApiControllerBase.cs | 4 +- ...lter.cs => WebAnalyticsExceptionFilter.cs} | 56 ++-- .../WebAnalyticsSettingsApiController.cs | 63 +++-- .../Models/AnalyticsModels.cs | 87 +++++- ...e.cs => AnalyticsConnectionNameService.cs} | 22 +- .../Services/AnalyticsDocumentRouteService.cs | 10 +- .../Services/AnalyticsProviderApiException.cs | 14 + ...er.cs => AnalyticsProviderClientRouter.cs} | 37 ++- ...ate.cs => AnalyticsProviderRequestGate.cs} | 6 +- ...rtService.cs => AnalyticsReportService.cs} | 44 +-- ...sClient.cs => IAnalyticsProviderClient.cs} | 24 +- ...lyticsClient.cs => MockAnalyticsClient.cs} | 30 +- .../Services/PlausibleAnalyticsClient.cs | 258 ++++++++++++++++++ .../Services/VercelAnalyticsApiException.cs | 9 - .../Services/VercelAnalyticsClient.cs | 32 +-- .../TheBuilder.WebAnalytics.csproj | 4 +- ...s => WebAnalyticsOptionsValidatorTests.cs} | 70 +++-- ... => WebAnalyticsSettingsValidatorTests.cs} | 52 ++-- .../WebAnalyticsApiControllerTests.cs | 30 +- ....cs => WebAnalyticsProblemFactoryTests.cs} | 52 ++-- .../WebAnalyticsSettingsApiControllerTests.cs | 51 +++- ...=> AnalyticsConnectionNameServiceTests.cs} | 34 +-- .../AnalyticsDocumentRouteServiceTests.cs | 6 +- ...ests.cs => AnalyticsReportServiceTests.cs} | 80 +++--- ...ntTests.cs => MockAnalyticsClientTests.cs} | 34 +-- .../Services/PlausibleAnalyticsClientTests.cs | 128 +++++++++ .../Services/VercelAnalyticsClientTests.cs | 18 +- umbraco-marketplace-readme.md | 4 +- 54 files changed, 1573 insertions(+), 737 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs delete mode 100644 src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsConnectionRegistry.cs delete mode 100644 src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptionsValidator.cs rename src/TheBuilder.WebAnalytics/Configuration/{VercelAnalyticsOptions.cs => WebAnalyticsOptions.cs} (72%) create mode 100644 src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs rename src/TheBuilder.WebAnalytics/Configuration/{VercelAnalyticsSettings.cs => WebAnalyticsSettings.cs} (69%) rename src/TheBuilder.WebAnalytics/Configuration/{VercelAnalyticsSettingsValidator.cs => WebAnalyticsSettingsValidator.cs} (57%) rename src/TheBuilder.WebAnalytics/Controllers/{VercelAnalyticsExceptionFilter.cs => WebAnalyticsExceptionFilter.cs} (53%) rename src/TheBuilder.WebAnalytics/Services/{VercelProjectNameService.cs => AnalyticsConnectionNameService.cs} (68%) create mode 100644 src/TheBuilder.WebAnalytics/Services/AnalyticsProviderApiException.cs rename src/TheBuilder.WebAnalytics/Services/{VercelAnalyticsClientRouter.cs => AnalyticsProviderClientRouter.cs} (65%) rename src/TheBuilder.WebAnalytics/Services/{VercelAnalyticsRequestGate.cs => AnalyticsProviderRequestGate.cs} (83%) rename src/TheBuilder.WebAnalytics/Services/{VercelAnalyticsReportService.cs => AnalyticsReportService.cs} (78%) rename src/TheBuilder.WebAnalytics/Services/{IVercelAnalyticsClient.cs => IAnalyticsProviderClient.cs} (77%) rename src/TheBuilder.WebAnalytics/Services/{MockVercelAnalyticsClient.cs => MockAnalyticsClient.cs} (92%) create mode 100644 src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs delete mode 100644 src/TheBuilder.WebAnalytics/Services/VercelAnalyticsApiException.cs rename tests/TheBuilder.WebAnalytics.Tests/Configuration/{VercelAnalyticsOptionsValidatorTests.cs => WebAnalyticsOptionsValidatorTests.cs} (70%) rename tests/TheBuilder.WebAnalytics.Tests/Configuration/{VercelAnalyticsSettingsValidatorTests.cs => WebAnalyticsSettingsValidatorTests.cs} (83%) rename tests/TheBuilder.WebAnalytics.Tests/Controllers/{VercelAnalyticsProblemFactoryTests.cs => WebAnalyticsProblemFactoryTests.cs} (60%) rename tests/TheBuilder.WebAnalytics.Tests/Services/{VercelProjectNameServiceTests.cs => AnalyticsConnectionNameServiceTests.cs} (64%) rename tests/TheBuilder.WebAnalytics.Tests/Services/{VercelAnalyticsReportServiceTests.cs => AnalyticsReportServiceTests.cs} (83%) rename tests/TheBuilder.WebAnalytics.Tests/Services/{MockVercelAnalyticsClientTests.cs => MockAnalyticsClientTests.cs} (88%) create mode 100644 tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs diff --git a/README.md b/README.md index 9bdccc0..7944e26 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![NuGet downloads](https://img.shields.io/nuget/dt/TheBuilder.WebAnalytics)](https://www.nuget.org/packages/TheBuilder.WebAnalytics) [![License](https://img.shields.io/github/license/thebuilder/web-analytics)](https://github.com/thebuilder/web-analytics/blob/main/LICENSE) -Web Analytics brings [Vercel Web Analytics](https://vercel.com/docs/analytics) into Umbraco, giving editors site-wide and page-level insights without leaving the backoffice. +Web Analytics brings analytics from [Vercel Web Analytics](https://vercel.com/docs/analytics) and [Plausible](https://plausible.io/docs/stats-api) into Umbraco, giving editors site-wide and page-level insights without leaving the backoffice. ![Web Analytics overview in the Umbraco backoffice](https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/analytics-overview.png) @@ -13,17 +13,17 @@ Web Analytics brings [Vercel Web Analytics](https://vercel.com/docs/analytics) i - A dedicated **Analytics** section for visitors, page views, traffic trends, audience, referrers, pages, routes, campaigns, custom events, and feature flags. - Page-level analytics on configured, published documents, automatically filtered to the document's route. - Date comparisons and drill-down views that turn site-wide trends into useful content context. -- Multiple Vercel project connections for multi-site Umbraco installations. -- Server-side Vercel API access, keeping access tokens out of the browser and Umbraco content. +- Multiple provider connections for multi-site Umbraco installations. +- Server-side provider API access, keeping access tokens out of the browser and Umbraco content. -The package reads analytics already collected by Vercel. It does not add or replace the tracking on your website. +The package reads analytics already collected by the configured provider. It does not add or replace tracking on your website. ## Requirements - Umbraco CMS 17.1–18.x. -- A Vercel project with [Web Analytics enabled and installed](https://vercel.com/docs/analytics/quickstart). -- A [Vercel access token](https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token) scoped to the personal account or team that owns the project. -- The Vercel project ID (`prj_...`). +- Either a Vercel project with [Web Analytics enabled](https://vercel.com/docs/analytics/quickstart), or a Plausible Cloud site with Stats API access. +- A provider API token: a scoped [Vercel access token](https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token) or [Plausible Stats API key](https://plausible.io/docs/stats-api#authentication). +- The Vercel project ID (`prj_...`) or Plausible site ID (normally its registered domain). ## Install @@ -42,20 +42,21 @@ Build and deploy the Umbraco application as usual. The package's `App_Plugins` a Configuration uses two sources: - Project details, mappings, and display settings are stored in Umbraco. -- The Vercel access token stays in the application's secret configuration. +- Provider access tokens stay in the application's secret configuration. -### 1. Create a Vercel access token +### 1. Create a provider access token -Create a token in the Vercel account settings and scope it to the account or team that owns the project. Copy it when it is created; Vercel does not show it again. +For Vercel, create a token in the account settings and scope it to the account or team that owns the project. For Plausible, create a Stats API key from the account API Keys settings. Copy either credential when it is created. For a team-owned project, also copy either the team ID (`team_...`) or team slug. Personal projects do not need either value. The backoffice presents these as one **Team ID or slug** field. See the [Vercel REST API authentication documentation](https://vercel.com/docs/rest-api). ### 2. Add the token to the Umbraco deployment -Configure one shared token for the package: +Configure a shared token for each provider you use: ```text WebAnalytics__Providers__Vercel__AccessToken +WebAnalytics__Providers__Plausible__AccessToken ``` Examples: @@ -63,6 +64,7 @@ Examples: ```sh # Local shell or container environment export WebAnalytics__Providers__Vercel__AccessToken="your_token" +export WebAnalytics__Providers__Plausible__AccessToken="your_stats_api_key" # .NET user-secrets dotnet user-secrets init \ @@ -78,27 +80,27 @@ Use the equivalent secret/app-setting facility in Azure App Service, Kubernetes, Restart every Umbraco application instance after adding or rotating a token. Tokens are loaded from server configuration at application startup. -The shared token is used by every connection. If a project must use a different token, expand **Token override** for that connection and copy the generated environment-variable name. Overrides use `WebAnalytics__ConnectionAccessTokens__{connection-guid}`. +Each provider token is used by connections of that provider. If a connection needs a different credential, expand **Token override** and use `WebAnalytics__ConnectionAccessTokens__{connection-guid}`. ### 3. Configure the connection in Umbraco Sign in as an administrator and open **Settings → Web Analytics**. 1. Select **Add connection**. -2. Enter the Vercel project ID. The project name is loaded from Vercel. -3. For a team project, enter its team ID or slug in the combined **Team ID or slug** field. +2. Choose Vercel or Plausible when adding the connection. The provider is fixed after creation. +3. Enter the Vercel project ID and optional team, or the Plausible site ID. 4. Configure page analytics mappings if document-level reports are required. 5. Select the document types that should display the Analytics workspace view, or enable all document types. 6. Enable the package. The first connection is used as the initial default. 7. Select **Save settings**, then **Test connection**. -The test confirms that Vercel accepts the token, project, and team configuration. The settings screen reports whether the shared token or a connection override was found; it never displays or stores the token itself. +The test confirms that the provider accepts the token and connection identifier. The settings screen reports whether the provider token or a connection override was found; it never displays or stores the token itself. ## Document analytics mappings Mappings are optional. A connection without mappings is available in the global Analytics section but does not add reports to document workspaces. -For document analytics, select each Umbraco site's root document. The nearest mapped ancestor determines which Vercel connection a document uses. +For document analytics, select each Umbraco site's root document. The nearest mapped ancestor determines which analytics connection a document uses. The document Analytics view is shown only when all of these conditions are met: @@ -128,6 +130,7 @@ The backoffice settings screen is the normal configuration path. A deployment ca "Connections": [ { "Key": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "Provider": "Vercel", "ProjectId": "prj_...", "Team": "team_...", "DocumentRootKeys": [ @@ -143,7 +146,7 @@ The backoffice settings screen is the normal configuration path. A deployment ca } ``` -`Team` accepts either a Vercel team ID beginning with `team_` or a team slug. Leave it empty for a personal project. +For Plausible, use `"Provider": "Plausible"` and set `SiteId` instead of `ProjectId` and `Team`. ### Configuration reference @@ -153,9 +156,10 @@ Package settings use the `WebAnalytics` section. | --- | --- | --- | | `Enabled` | `false` | Enables the Analytics section and configured document workspace views. | | `Providers:Vercel:AccessToken` | Empty | Shared Vercel access token used by every connection. Supply through secret configuration. | +| `Providers:Plausible:AccessToken` | Empty | Shared Plausible Stats API key used by every Plausible connection. Supply through secret configuration. | | `DefaultRangeDays` | `30` | Initial report range in days. Valid values are 1–730. | | `CacheDuration` | `00:05:00` | Per-instance in-memory cache duration. Valid values are zero to one hour. | -| `Connections` | `[]` | Vercel project connection definitions. The first connection becomes the initial default. | +| `Connections` | `[]` | Provider connection definitions. The first connection becomes the initial default. | | `ConnectionAccessTokens` | Empty | Optional secret dictionary keyed by a connection GUID. Prefer the copyable environment-variable name shown in the settings UI. | Each entry under `Connections` supports: @@ -164,14 +168,16 @@ Each entry under `Connections` supports: | --- | --- | --- | | `Key` | Generated GUID | Stable internal identity. The settings UI creates this automatically; provide a fixed GUID for deterministic configuration-only setup. | | `DisplayName` | Empty | Cached project name used until the name can be loaded from Vercel. | -| `ProjectId` | Required | Vercel project ID beginning with `prj_`. | +| `Provider` | `Vercel` | Analytics provider: `Vercel` or `Plausible`. | +| `ProjectId` | Vercel only | Vercel project ID beginning with `prj_`. | | `Team` | Empty | Optional team ID (`team_...`) or team slug. Leave empty for a personal project. | +| `SiteId` | Plausible only | Plausible site ID, normally the registered domain. | | `DocumentRootKeys` | `[]` | Umbraco document-root GUIDs that map document analytics to this connection. | | `EnableAllDocumentTypes` | `false` | Shows document analytics for every document type beneath a mapped root. | | `EnabledDocumentTypeKeys` | `[]` | Document-type GUIDs that show document analytics when all types are not enabled. | | `EnabledDocumentTypes` | `[]` | Document-type aliases used by configuration-only bootstrapping. Prefer stable document-type keys for settings managed in Umbraco. | -Keep tokens out of the JSON file. Supply the shared token through `WebAnalytics__Providers__Vercel__AccessToken`; only use `WebAnalytics__ConnectionAccessTokens__{connection-guid}` when one connection requires an override. +Keep tokens out of the JSON file. Supply provider tokens through `WebAnalytics__Providers__Vercel__AccessToken` or `WebAnalytics__Providers__Plausible__AccessToken`; only use `WebAnalytics__ConnectionAccessTokens__{connection-guid}` for an override. Before the settings screen has saved anything, Umbraco uses these server options as the initial configuration. After an administrator saves the settings screen, the non-secret settings are stored in Umbraco's database and become the source of truth. Access tokens continue to come from server configuration, with a connection-specific token taking precedence over the shared token. @@ -189,15 +195,17 @@ After deployment: 4. If document analytics is enabled, open a mapped published document and select its **Analytics** workspace view. 5. Grant the Analytics section to any non-administrator user groups that need global reports. -The available reporting window and some dimensions depend on the Vercel plan and the data recorded by the project. Unsupported optional panels are hidden rather than treated as connection failures. +The available reporting window and dimensions depend on the provider, plan, and recorded data. Unsupported panels are hidden rather than treated as connection failures. Plausible supports goal/custom-event lists but not Vercel feature flags or event-property drilldowns. ## Troubleshooting | Symptom | Check | | --- | --- | -| **Token missing** | Configure `WebAnalytics__Providers__Vercel__AccessToken`, or a connection-specific override, and restart the application. | +| **Token missing** | Configure the matching `WebAnalytics__Providers__{Provider}__AccessToken`, or a connection-specific override, and restart the application. | | Vercel returns `401` or `403` | Confirm the token is valid, scoped to the owning account/team, and has access to the configured project. | | Vercel returns `400` | Verify the project ID and the optional `Team` value. | +| Plausible returns `401` or `403` | Confirm the Stats API key can read the configured site. | +| Plausible rejects the query | Verify `SiteId` exactly matches the domain registered in Plausible. | | Analytics section is not visible | Add the Analytics section to the user's Umbraco user group. The automatic administrator grant runs only once. | | Document Analytics view is not visible | Confirm the document is published, beneath a configured document root, uses an enabled document type, and the user can read it. | | No data appears | Confirm Web Analytics is enabled and installed on the public site, production traffic has been recorded, and the selected date is inside Vercel's reporting window. | @@ -205,7 +213,7 @@ The available reporting window and some dimensions depend on the Vercel plan and ## Development -The example app opts into development data through `WebAnalytics:EnableMockConnections` in its development settings. **Settings → Web Analytics** then includes presets for a full demo, UTM campaigns, feature flags, and custom events. Add and save a mock connection like any other connection, then select it in the Analytics dashboard. Mock reports are deterministic, require no access token, and never call Vercel. The package keeps mock connections disabled by default, including when it is consumed by another project running in Development. +The example app opts into development data through `WebAnalytics:EnableMockConnections` in its development settings. **Settings → Web Analytics** then includes presets for a full demo, UTM campaigns, feature flags, and custom events. Mock reports are deterministic, require no access token, and never call an external provider. The package keeps mock connections disabled by default. The client uses pnpm 11. From `src/TheBuilder.WebAnalytics/Client`: diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index 672f3a8..3a36159 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -24,6 +24,9 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem @property() acquisitionView: AcquisitionView = "referrers"; @property() utmDimension: UtmDimension = "UtmSource"; @property() baseUrl?: string; + @property({ type: Boolean }) supportsEvents = true; + @property({ type: Boolean }) supportsEventProperties = true; + @property({ type: Boolean }) supportsFlags = true; #dispatch(name: string, detail?: unknown): void { this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail })); @@ -187,7 +190,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
- + ${empty ? "" : html`
${!loading && rows.length ? html` this.#dispatch("view-events")}>View all` : ""}
`} @@ -207,11 +210,11 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
${cardsBeforeEvents.map(renderCard)} - ${this.#renderEvents()} + ${this.supportsEvents ? this.#renderEvents() : ""} ${cardsAfterEvents.map(renderCard)} - + ${this.supportsFlags ? html` - + ` : ""}
`; } 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 015057c..8055a4c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -63,6 +63,8 @@ describe("analytics presentation components", () => { element.siteUrl = "https://example.com"; element.route = { connection: "11111111-1111-1111-1111-111111111111", + provider: "Vercel", + capabilities: { dimensions: ["RequestPath"], events: true, eventProperties: true, flags: true }, culture: "en-US", hostname: "example.com", path: "/products/example", 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 d81eb3b..b4aa798 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 @@ -1,9 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import type { AnalyticsDocumentRoute } from "../api/types.gen.js"; +import type { AnalyticsCapabilities, AnalyticsDocumentRoute } from "../api/types.gen.js"; import { AnalyticsDashboardController, type DashboardEnvironment } from "./analytics-dashboard.controller.js"; import type { DashboardApi } from "./dashboard-api.js"; import { dateRangeForPreset } from "./date-range.js"; +const fullCapabilities: AnalyticsCapabilities = { + dimensions: ["RequestPath", "Route", "ReferrerHostname", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "EventName"], + events: true, + eventProperties: true, + flags: true, +}; + describe("AnalyticsDashboardController", () => { it("uses the first connection when no requested or stored connection is valid", async () => { const api = dashboardApi(); @@ -11,8 +18,8 @@ describe("AnalyticsDashboardController", () => { enabled: true, defaultRangeDays: 30, connections: [ - { key: "11111111-1111-1111-1111-111111111111", displayName: "First", isDefault: true, isConfigured: true, baseUrl: "https://first.example.com", warnings: [] }, - { key: "22222222-2222-2222-2222-222222222222", displayName: "Second", isDefault: false, isConfigured: true, baseUrl: "https://second.example.com", warnings: [] }, + { key: "11111111-1111-1111-1111-111111111111", displayName: "First", provider: "Vercel", capabilities: fullCapabilities, isDefault: true, isConfigured: true, baseUrl: "https://first.example.com", warnings: [] }, + { key: "22222222-2222-2222-2222-222222222222", displayName: "Second", provider: "Vercel", capabilities: fullCapabilities, isDefault: false, isConfigured: true, baseUrl: "https://second.example.com", warnings: [] }, ], })); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); @@ -29,8 +36,8 @@ describe("AnalyticsDashboardController", () => { enabled: true, defaultRangeDays: 30, connections: [ - { key: "11111111-1111-1111-1111-111111111111", displayName: "Demo", isDefault: true, isConfigured: true, baseUrl: "https://demo.example.com", warnings: [] }, - { key: "22222222-2222-2222-2222-222222222222", displayName: "Unconfigured", isDefault: false, isConfigured: false, baseUrl: undefined, warnings: ["No server-side access token is configured for this connection."] }, + { key: "11111111-1111-1111-1111-111111111111", displayName: "Demo", provider: "Vercel", capabilities: fullCapabilities, isDefault: true, isConfigured: true, baseUrl: "https://demo.example.com", warnings: [] }, + { key: "22222222-2222-2222-2222-222222222222", displayName: "Unconfigured", provider: "Vercel", capabilities: fullCapabilities, isDefault: false, isConfigured: false, baseUrl: undefined, warnings: ["No server-side access token is configured for this connection."] }, ], })); const nextEvents = deferred>>(); @@ -308,6 +315,39 @@ describe("AnalyticsDashboardController", () => { expect(api.summary).not.toHaveBeenCalled(); }); + it("uses provider capabilities to skip flags and event details for Plausible", async () => { + const api = dashboardApi(); + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Plausible", + provider: "Plausible", + capabilities: { + dimensions: ["RequestPath", "ReferrerHostname", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "EventName"], + events: true, + eventProperties: false, + flags: false, + }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await controller.selectEvent("Signup"); + + expect(api.events).toHaveBeenCalled(); + expect(api.flags).not.toHaveBeenCalled(); + expect(api.eventDetails).not.toHaveBeenCalled(); + expect(controller.cards().some((card) => card.kind === "tabbed-breakdown" && card.id === "utm")).toBe(true); + }); + }); function dashboardApi() { @@ -315,7 +355,7 @@ function dashboardApi() { connections: vi.fn(async () => ok({ enabled: true, defaultRangeDays: 30, - connections: [{ key: "11111111-1111-1111-1111-111111111111", displayName: "Main", isDefault: true, isConfigured: true, baseUrl: "https://example.com", warnings: [] }], + connections: [{ key: "11111111-1111-1111-1111-111111111111", displayName: "Main", provider: "Vercel", capabilities: fullCapabilities, isDefault: true, isConfigured: true, baseUrl: "https://example.com", warnings: [] }], })), documentRoutes: vi.fn(async () => ok([])), summary: vi.fn(async () => ok({ totals: { visitors: 10, pageViews: 20 }, points: [] })), @@ -328,7 +368,7 @@ function dashboardApi() { } function route(path: string, culture: string): AnalyticsDocumentRoute { - return { connection: "11111111-1111-1111-1111-111111111111", culture, hostname: "example.com", path, url: `https://example.com${path}`, isCurrent: true, warnings: [] }; + return { connection: "11111111-1111-1111-1111-111111111111", provider: "Vercel", capabilities: fullCapabilities, culture, hostname: "example.com", path, url: `https://example.com${path}`, isCurrent: true, warnings: [] }; } function environment(): DashboardEnvironment { 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 83f6366..d81d53d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -1,5 +1,6 @@ import type { AnalyticsBreakdown, + AnalyticsCapabilities, AnalyticsConnectionSummary, AnalyticsDimension, AnalyticsDocumentRoute, @@ -195,13 +196,15 @@ export class AnalyticsDashboardController { if (!connection) return; this.#closeDialogs(); this.#utmRequest.cancel(); - const utmCapability = this.#utmCapabilityByConnection.get(connection) ?? "unknown"; + const capabilities = this.#capabilities(); + const supportsUtm = capabilities.dimensions.includes("UtmSource"); + const utmCapability = supportsUtm ? this.#utmCapabilityByConnection.get(connection) ?? "unknown" : "unavailable"; const { dimensions } = this.#dashboardReportPlan(utmCapability); this.#set({ utmCapability, summary: loadingState(this.state.summary), - events: loadingState(this.state.events), - flags: loadingState(this.state.flags), + events: capabilities.events ? loadingState(this.state.events) : idleState(), + flags: capabilities.flags ? loadingState(this.state.flags) : idleState(), breakdowns: Object.fromEntries(dimensions.map((dimension) => [dimension, loadingState(this.state.breakdowns[dimension])])), }); const visitQuery = this.#reportQuery(connection, this.#visitFilterQuery()); @@ -213,16 +216,17 @@ export class AnalyticsDashboardController { signal, (update) => this.#applyReportUpdate(update), this.#api, + capabilities, )); if (result.status !== "success") { if (result.status === "error") this.#failLoadingReports(reportErrorMessage(result.error), dimensions); return; } - const capability = detectUtmCapability( + const capability = supportsUtm ? detectUtmCapability( result.value.baselineSucceeded, result.value.utmSucceeded, result.value.utmStatuses, - ); + ) : "unavailable"; if (capability !== "unknown") { this.#utmCapabilityByConnection.set(connection, capability); this.#set({ utmCapability: capability }); @@ -322,6 +326,7 @@ export class AnalyticsDashboardController { closeBreakdown(): void { this.#expandedRequest.cancel(); this.#set({ expandedBreakdown: undefined }); } async openEvents(search = "", debounce = false): Promise { + if (!this.#capabilities().events) return; const connection = this.state.connection; if (!connection) return; const previous = this.state.expandedEvents; @@ -341,6 +346,7 @@ export class AnalyticsDashboardController { closeEvents(): void { this.#eventSearchRequest.cancel(); this.#set({ expandedEvents: undefined }); } async selectFlag(flagKey: string): Promise { + if (!this.#capabilities().flags) return; const connection = this.state.connection; if (!connection) return; const previous = this.state.selectedFlag; @@ -358,7 +364,11 @@ export class AnalyticsDashboardController { clearSelectedFlag(): void { this.#flagRequest.cancel(); this.#set({ selectedFlag: undefined }); } - async selectEvent(eventName: string): Promise { this.closeEvents(); await this.#loadEventDetails(eventName); } + async selectEvent(eventName: string): Promise { + if (!this.#capabilities().eventProperties) return; + this.closeEvents(); + await this.#loadEventDetails(eventName); + } toggleEventPropertyFilter(property: string, value: string): void { const selected = this.state.selectedEvent; @@ -401,7 +411,7 @@ export class AnalyticsDashboardController { if (result.status === "error") { this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); return; } const { data, error } = result.value; if (error || !data?.enabled) { - this.#set({ configurationError: "Vercel Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); + this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); return; } if (data.connections.length === 0) { @@ -452,12 +462,34 @@ export class AnalyticsDashboardController { } #dashboardReportPlan(utmCapability = this.state.utmCapability): DashboardReportPlan { - return dashboardReportPlan( + const plan = dashboardReportPlan( Boolean(this.#documentId), utmCapability, this.state.acquisitionView, this.state.utmDimension, ); + const supported = new Set(this.#capabilities().dimensions); + const cards = plan.cards.reduce((result, card) => { + if (card.kind === "breakdown") { + if (supported.has(card.dimension)) result.push(card); + return result; + } + const options = card.options.filter(({ dimension }) => supported.has(dimension)); + if (options.length) result.push({ ...card, options }); + return result; + }, []); + return { cards, dimensions: plan.dimensions.filter((dimension) => supported.has(dimension)) }; + } + + #capabilities(): AnalyticsCapabilities { + const route = this.state.route; + if (route) return route.capabilities; + return this.state.connections.find(({ key }) => key === this.state.connection)?.capabilities ?? { + dimensions: ["RequestPath", "Route", "ReferrerHostname", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "EventName"], + events: true, + eventProperties: true, + flags: true, + }; } #ensureUtmBreakdown(dimension: UtmDimension): void { 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 196a5de..c205517 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -82,7 +82,7 @@ export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) if (state.setupRequired) return html`
-

Add a Vercel project connection before viewing analytics.

+

Add an analytics connection before viewing reports.

key === state.connection)?.capabilities; return html`
) => this.#controller.toggleFilter(event.detail.dimension, event.detail.value)}> ) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} @view-events=${() => this.#controller.openEvents()} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} @@ -153,13 +157,14 @@ export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) ) => this.#controller.openEvents(event.detail.search, true)} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} @close-events=${() => this.#controller.closeEvents()}> ` : ""} - ${selected ? html` + ${selected && (capabilities?.eventProperties ?? true) ? html` void, api: ReportApi = dashboardApi, + capabilities: { events: boolean; flags: boolean } = { events: true, flags: true }, ): Promise { let baselineSucceeded = false; let utmSucceeded = false; @@ -41,12 +42,12 @@ export async function loadDashboardReports( if (report.status === "success") baselineSucceeded = true; publish({ panel: "summary", ...report }); }), - () => settleRequest(api.events({ query: { ...eventQuery, limit: 20 }, signal })).then((result) => { + ...(capabilities.events ? [() => settleRequest(api.events({ query: { ...eventQuery, limit: 20 }, signal })).then((result) => { publish({ panel: "events", ...toLoadedReport(result) }); - }), - () => settleRequest(api.flags({ query: { ...visitQuery, limit: 10 }, signal })).then((result) => { + })] : []), + ...(capabilities.flags ? [() => settleRequest(api.flags({ query: { ...visitQuery, limit: 10 }, signal })).then((result) => { publish({ panel: "flags", ...toLoadedReport(result) }); - }), + })] : []), ...dimensions.map((dimension) => async () => { const { update, responseStatus } = await loadDashboardBreakdown(visitQuery, dimension, signal, api); if (isUtmDimension(dimension)) { 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 c5b235f..613046f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts @@ -4,6 +4,8 @@ import { activeDocumentRoute, workspaceAnalyticsCulture } from "./document-route const route = (culture: string, isCurrent = false): AnalyticsDocumentRoute => ({ connection: "main", + provider: "Vercel", + capabilities: { dimensions: ["RequestPath"], events: true, eventProperties: true, flags: true }, culture, hostname: "www.example.com", path: `/${culture}`, diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts index a930eda..37479e6 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -12,6 +12,7 @@ export class VercelAnalyticsEventDialogElement extends UmbElementMixin(LitElemen @property() unavailable?: string; @property({ attribute: false }) rows: AnalyticsEventRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ type: Boolean }) detailsEnabled = true; @state() private _search = ""; protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } @@ -34,7 +35,7 @@ export class VercelAnalyticsEventDialogElement extends UmbElementMixin(LitElemen ${!this.loading && this.unavailable ? html`

${this.unavailable}

` : ""} ${!this.loading && !this.unavailable && this._search && this.rows.length === 0 ? html`

Try a different search.

` : ""} ${this.loading || (!this.unavailable && (!this._search || this.rows.length > 0)) ? html` - + ` : ""}
Close diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts index 1d4b052..b6e6454 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts @@ -12,6 +12,7 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement @property({ type: Number }) skeletonRows = 10; @property({ attribute: false }) rows: AnalyticsEventRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ type: Boolean }) detailsEnabled = true; #select(eventName: string): void { this.dispatchEvent(new CustomEvent("select-event", { @@ -41,7 +42,9 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement 0 ? "4px" : "0px"}`}> - + ${this.detailsEnabled + ? html`` + : html`${row.eventName}`}