diff --git a/DESIGN.md b/DESIGN.md index 37ed6d4..d49febb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,6 @@ --- name: Web Analytics -description: A native editorial instrument for clear Vercel traffic insight inside Umbraco. +description: A native editorial instrument for clear web analytics insight inside Umbraco. colors: backoffice-ink: "#060606" backoffice-ink-muted: "#2e2b29" @@ -88,9 +88,9 @@ components: **Creative North Star: "The Editorial Instrument"** -Web Analytics is a precise but approachable instrument inside the editorial workspace. It should reveal the traffic signal an editor needs, then get out of the way. The visual language is friendly and conversational, but its structure is disciplined: familiar Umbraco controls, clear hierarchy, compact reporting, and feedback close to the action that caused it. +Web Analytics is a precise but approachable instrument inside the editorial workspace. It should reveal the site-wide or page-level traffic signal an editor needs, then get out of the way. The visual language is friendly and conversational, but its structure is disciplined: familiar Umbraco controls, clear hierarchy, compact reporting, capability-aware depth, and feedback close to the action that caused it. -The extension belongs to the backoffice rather than merely living inside it. It inherits Umbraco UI Library semantics, Lato typography, spacing, focus treatment, and surface hierarchy. Custom analytics patterns may borrow the clarity of Vercel Analytics, but they must be translated into Umbraco's interaction language. +The extension belongs to the backoffice rather than merely living inside it. It inherits Umbraco UI Library semantics, Lato typography, spacing, focus treatment, and surface hierarchy. Custom analytics patterns may borrow the clarity of Vercel Analytics, but Umbraco owns the interaction language: provider capabilities change the available depth, not the identity or basic behavior of the interface. The system explicitly rejects the density, configuration burden, and specialist terminology of an overly dense enterprise analytics platform. It is not Google Analytics, and it must never feel like a separate third-party dashboard embedded in Umbraco. diff --git a/PRODUCT.md b/PRODUCT.md index d741252..20666d0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -14,13 +14,15 @@ The primary users are content editors and marketing staff working in the authent ## Product Purpose -Web Analytics brings Vercel Web Analytics into the Umbraco backoffice. It helps editors understand site and page traffic where they already manage content, while administrators retain secure control of project connections and server-side credentials. +Web Analytics brings insights from Vercel Web Analytics and Plausible into the Umbraco editorial workflow. It gives editors clear site-wide and page-level traffic context where they already manage content, while administrators retain secure control of connections and server-side credentials. -Success means that an editor can open Analytics, understand the important traffic signals quickly, and return to their content work without needing to visit Vercel or a separate analytics suite. +Vercel and Plausible are bundled integrations. Reporting depth adapts to the capabilities and data available from the selected provider, while the editorial experience remains consistent and focused. + +Success means that an editor can open Analytics, understand the important traffic signals quickly, and return to their content work without needing to visit a separate analytics suite. ## Positioning -Vercel traffic insights where editors already work, without exposing credentials or requiring another analytics dashboard. +Clear site-wide and page-level insight where editors already work, without exposing credentials or requiring another analytics dashboard. ## Brand Personality diff --git a/README.md b/README.md index 9bdccc0..0e5be09 100644 --- a/README.md +++ b/README.md @@ -4,30 +4,34 @@ [![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 Vercel and Plausible analytics 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) ## What you get -- A dedicated **Analytics** section for visitors, page views, traffic trends, audience, referrers, pages, routes, campaigns, custom events, and feature flags. +- A dedicated **Analytics** section for visitors, page views, traffic trends, audience, referrers, pages, routes, campaigns, and provider-supported activity. - 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 +## Supported providers -- 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_...`). +The connection and reporting workflow is provider-neutral. Availability of individual panels and drill-downs depends on the selected provider's capabilities. + +| Provider | Connection identifier | Credential | Provider-specific capabilities | +| --- | --- | --- | --- | +| [Vercel Web Analytics](https://vercel.com/docs/analytics) | Project ID (`prj_...`) and optional team | [Scoped access token](https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token) | Custom-event property exploration and feature flags | +| [Plausible](https://plausible.io/docs/stats-api) | Site ID, normally the registered domain | [Stats API key](https://plausible.io/docs/stats-api#authentication) | Goal and custom-event totals, filtering, and property drill-downs | + +Plausible's Stats API requires a Business plan. Self-hosted Plausible is not currently supported. ## Install -Add the package to the Umbraco web project: +Web Analytics supports Umbraco CMS 17.1–18.x. Add the package to the Umbraco web project: ```sh dotnet add path/to/Your.Umbraco.Web.csproj package TheBuilder.WebAnalytics @@ -42,20 +46,27 @@ 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 credentials stay in the application's secret configuration. + +### 1. Create a provider credential + +Create a credential for the provider and grant it read access to the site or project being connected. -### 1. Create a Vercel access token +#### Vercel -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. +Create a token in the account settings and scope it to the account or team that owns the project. For a team-owned project, also copy either the team ID (`team_...`) or team slug. -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). +#### Plausible -### 2. Add the token to the Umbraco deployment +Create a [Stats API](https://plausible.io/docs/stats-api) key from the Plausible account API Keys settings. The site ID must exactly match the domain registered in Plausible. -Configure one shared token for the package: +### 2. Add the credential to the Umbraco deployment + +Configure a shared credential for each provider you use: ```text WebAnalytics__Providers__Vercel__AccessToken +WebAnalytics__Providers__Plausible__AccessToken ``` Examples: @@ -63,6 +74,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 \ @@ -74,31 +86,31 @@ dotnet user-secrets set \ --project path/to/Your.Umbraco.Web.csproj ``` -Use the equivalent secret/app-setting facility in Azure App Service, Kubernetes, Docker, or the hosting platform. Do not commit the token to `appsettings.json` or source control. +Use the equivalent secret/app-setting facility in Azure App Service, Kubernetes, Docker, or the hosting platform. Do not commit credentials to `appsettings.json` or source control. -Restart every Umbraco application instance after adding or rotating a token. Tokens are loaded from server configuration at application startup. +Restart every Umbraco application instance after adding or rotating a credential. Credentials 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 credential 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 a provider when adding the connection. The provider is fixed after creation. +3. Enter the identifier requested for that provider. 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 +140,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 +156,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`. Add `EventPropertyNames` when the site records custom event properties beyond Plausible's built-in `url` property for outbound-link and file-download goals and `path` property for 404 goals. ### Configuration reference @@ -151,29 +164,40 @@ Package settings use the `WebAnalytics` section. | Key | Default | Description | | --- | --- | --- | -| `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. | +| `Enabled` | `true` | Enables the Analytics section and configured document workspace views. | | `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. | +#### Provider credentials + +Provider credentials are shared by every connection using that provider unless a connection-specific override is configured. + +| Provider | Configuration key | Description | +| --- | --- | --- | +| Vercel | `Providers:Vercel:AccessToken` | Scoped access token for the account or team that owns the configured projects. | +| Plausible | `Providers:Plausible:AccessToken` | Stats API key from a Plausible Business account. | + Each entry under `Connections` supports: | Key | Default | Description | | --- | --- | --- | | `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_`. | +| `DisplayName` | Empty | Cached connection name used until a display name can be loaded from the provider. | +| `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. | +| `EventPropertyNames` | `[]` | Plausible-only custom event property names to include in event-property discovery and drill-downs. Up to 20 names, each at most 100 characters. Plausible's `url` property is built in for outbound-link and file-download goals, and `path` is built in for 404 goals. | | `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 credentials out of `appsettings.json` and source control. Supply the provider keys above through secret configuration. A connection-specific credential under `ConnectionAccessTokens` takes precedence over its provider credential. -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. +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. In a load-balanced deployment, restart all Umbraco instances after changing saved connection settings or server-side tokens so every process uses the same configuration. @@ -183,29 +207,31 @@ The default cache duration is five minutes. Each Umbraco instance maintains its After deployment: -1. Open **Settings → Web Analytics** and confirm the shared access token says **Configured on the server**. +1. Open **Settings → Web Analytics** and confirm the provider says **Shared credential detected**. 2. Select **Save settings**, then **Test connection**. 3. Open the global **Analytics** section and confirm totals and history load. 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 and custom-event lists, filtering, and event-property drill-downs, but not Vercel feature flags. Add custom property names to the Plausible connection when needed; `url` is built in for outbound-link and file-download goals, and `path` for 404 goals. ## 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. | +| No data appears | Confirm the selected provider is tracking the public site, production traffic has been recorded, and the selected date is inside the provider's available reporting window. | | Settings differ between application instances | Restart every instance after changing settings or tokens. | ## 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`: @@ -221,29 +247,16 @@ The generated API client is checked in with the package source. The example host Run the example host against the Umbraco version whose document you want to use: ```sh -# Umbraco 17 dotnet run \ --project samples/TheBuilder.WebAnalytics.Example \ -p:UmbracoVersion=17.4.0 - -# Umbraco 18 -dotnet run \ - --project samples/TheBuilder.WebAnalytics.Example \ - -p:UmbracoVersion=18.0.0 ``` -Use a separate database for each major when switching the example host between versions. Umbraco upgrades its database schema and does not support downgrading that database to an earlier major. - Then regenerate the client from the matching development endpoint: ```sh cd src/TheBuilder.WebAnalytics/Client -# Umbraco 17 -corepack pnpm generate-client -- \ +pnpm generate-client -- \ https://localhost:44389/umbraco/swagger/thebuilderwebanalytics/swagger.json - -# Umbraco 18 -corepack pnpm generate-client -- \ - https://localhost:44389/umbraco/openapi/thebuilderwebanalytics.json ``` diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 0ee3bb6..0e5e7ef 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/samples/TheBuilder.WebAnalytics.Example/VercelAnalyticsOpenApiComposer.cs b/samples/TheBuilder.WebAnalytics.Example/WebAnalyticsOpenApiComposer.cs similarity index 65% rename from samples/TheBuilder.WebAnalytics.Example/VercelAnalyticsOpenApiComposer.cs rename to samples/TheBuilder.WebAnalytics.Example/WebAnalyticsOpenApiComposer.cs index 0259a45..d182dfb 100644 --- a/samples/TheBuilder.WebAnalytics.Example/VercelAnalyticsOpenApiComposer.cs +++ b/samples/TheBuilder.WebAnalytics.Example/WebAnalyticsOpenApiComposer.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; using Umbraco.Cms.Api.Common.OpenApi; using Umbraco.Cms.Api.Management.OpenApi; #endif @@ -18,7 +19,7 @@ namespace TheBuilder.WebAnalytics.Example; -public sealed class VercelAnalyticsOpenApiComposer : IComposer +public sealed class WebAnalyticsOpenApiComposer : IComposer { public void Compose(IUmbracoBuilder builder) { @@ -42,7 +43,7 @@ public void Compose(IUmbracoBuilder builder) return Task.CompletedTask; }))); #else - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.Configure(options => { options.SwaggerDoc( @@ -52,18 +53,41 @@ public void Compose(IUmbracoBuilder builder) Title = "Web Analytics Backoffice API", Version = "1.0", }); - options.OperationFilter(); + options.OperationFilter(); + options.SchemaFilter(); }); #endif } #if !UMBRACO_18_OR_LATER - private sealed class VercelAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase + private sealed class WebAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase { protected override string ApiName => Constants.ApiName; } - private sealed class VercelAnalyticsOperationIdHandler(IOptions apiVersioningOptions) + private sealed class NullableEnumSchemaFilter : ISchemaFilter + { + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + foreach (var property in context.Type.GetProperties()) + { + if (Nullable.GetUnderlyingType(property.PropertyType)?.IsEnum is not true) + continue; + + var propertyName = JsonNamingPolicy.CamelCase.ConvertName(property.Name); + schema.Required?.Remove(propertyName); + if (schema.Properties?.TryGetValue(propertyName, out var propertySchema) is true) + { + schema.Properties[propertyName] = new OpenApiSchema + { + AnyOf = [propertySchema, new OpenApiSchema { Type = JsonSchemaType.Null }] + }; + } + } + } + } + + private sealed class WebAnalyticsOperationIdHandler(IOptions apiVersioningOptions) : OperationIdHandler(apiVersioningOptions) { protected override bool CanHandle( diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/providers/plausible.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/providers/plausible.svg new file mode 100644 index 0000000..b5d414c --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/providers/plausible.svg @@ -0,0 +1,4 @@ + + Plausible Analytics + + diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/providers/vercel.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/providers/vercel.svg new file mode 100644 index 0000000..de7a2d7 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/providers/vercel.svg @@ -0,0 +1,4 @@ + + Vercel + + diff --git a/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js b/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js index 69b0a38..d66b097 100644 --- a/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js +++ b/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js @@ -1,6 +1,7 @@ import fetch from 'node-fetch'; import chalk from 'chalk'; import { createClient, defaultPlugins } from '@hey-api/openapi-ts'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; const getSwaggerUrl = (argumentsToParse) => argumentsToParse.find((argument) => argument !== '--'); @@ -24,10 +25,23 @@ const printGenerationError = (error, message) => { error('Review the generator error and the client configuration in generate-openapi.js.'); }; +export async function normalizeGeneratedClient(output = 'src/api') { + const files = await readdir(output); + await Promise.all(files + .filter((file) => file.endsWith('.ts')) + .map(async (file) => { + const path = `${output}/${file}`; + const source = await readFile(path, 'utf8'); + const normalized = source.replace(/[\t ]+(?=\r?$)/gm, ''); + if (normalized !== source) await writeFile(path, normalized); + })); +} + export async function generateOpenApiClient({ swaggerUrl = getSwaggerUrl(process.argv.slice(2)), fetchImplementation = fetch, createClientImplementation = createClient, + normalizeGeneratedOutput = normalizeGeneratedClient, log = console.log, error = console.error, } = {}) { @@ -61,8 +75,9 @@ export async function generateOpenApiClient({ log(`Calling ${chalk.yellow('hey-api')} to generate TypeScript client`); try { + const openApiDocument = await response.json(); await createClientImplementation({ - input: swaggerUrl, + input: openApiDocument, output: 'src/api', plugins: [ ...defaultPlugins, @@ -70,10 +85,11 @@ export async function generateOpenApiClient({ { name: '@hey-api/sdk', asClass: true, - classNameBuilder: '{{name}}Service', + classNameBuilder: 'WebAnalyticsService', }, ], }); + await normalizeGeneratedOutput(); log('OpenAPI client generated successfully'); return true; } catch (exception) { diff --git a/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.test.js b/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.test.js index 7e8184a..20ebaf5 100644 --- a/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.test.js +++ b/src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.test.js @@ -1,8 +1,11 @@ import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { generateOpenApiClient } from './generate-openapi.js'; +import { generateOpenApiClient, normalizeGeneratedClient } from './generate-openapi.js'; const scriptPath = fileURLToPath(new URL('./generate-openapi.js', import.meta.url)); @@ -86,7 +89,7 @@ describe('generate-openapi', () => { const errors = []; const succeeded = await generateOpenApiClient({ swaggerUrl: 'http://localhost/openapi.json', - fetchImplementation: async () => ({ ok: true }), + fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }), createClientImplementation: async () => { throw new Error('generation failed'); }, @@ -104,12 +107,16 @@ describe('generate-openapi', () => { it('does not report success until the client generator resolves', async () => { const logs = []; let resolveGeneration; + let normalized = false; const generation = generateOpenApiClient({ swaggerUrl: 'http://localhost/openapi.json', - fetchImplementation: async () => ({ ok: true }), + fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }), createClientImplementation: () => new Promise((resolve) => { resolveGeneration = resolve; }), + normalizeGeneratedOutput: async () => { + normalized = true; + }, log: (message) => logs.push(message), error: () => {}, }); @@ -121,6 +128,22 @@ describe('generate-openapi', () => { resolveGeneration(); await expect(generation).resolves.toBe(true); + expect(normalized).toBe(true); expect(logs).toContain('OpenAPI client generated successfully'); }); + + it('normalizes trailing whitespace from generated TypeScript files', async () => { + const output = await mkdtemp(join(tmpdir(), 'web-analytics-openapi-')); + const generatedFile = join(output, 'sdk.gen.ts'); + try { + await writeFile(generatedFile, 'export const generated = true; \r\n \r\n'); + + await normalizeGeneratedClient(output); + + await expect(readFile(generatedFile, 'utf8')).resolves.toBe('export const generated = true;\r\n\r\n'); + } finally { + await rm(output, { recursive: true, force: true }); + } + }); + }); 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..3a3cf9c 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 @@ -11,8 +11,8 @@ import "./event-table.element.js"; import "./flag-card.element.js"; import { stateData, type AsyncState } from "./async-state.js"; -@customElement("vercel-analytics-breakdown-grid") -export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-breakdown-grid") +export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) cards: ReadonlyArray = []; @property({ attribute: false }) breakdowns: Partial>> = {}; @property({ attribute: false }) events: AsyncState = { status: "loading" }; @@ -24,6 +24,10 @@ 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 }) supportsGlobalEventFiltering = false; + @property({ type: Boolean }) supportsEventDetails = true; + @property({ type: Boolean }) supportsFlags = true; #dispatch(name: string, detail?: unknown): void { this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail })); @@ -112,7 +116,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
- ${card.kind === "tabbed-breakdown" ? this.#renderTabs(card) : ""} - - ${planLimited && unavailable ? html`

UTM reporting availability depends on your Vercel plan and reporting window.

` : ""} + + ${planLimited && unavailable ? html`

UTM reporting availability depends on your analytics plan and reporting window.

` : ""}
${!loading && !unavailable && rows.length ? html` this.#dispatch("view-breakdown", selected)}>View all @@ -154,7 +158,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
- ${this.#renderAcquisitionTabs(utmAvailable)} ${showingUtm ? this.#renderUtmTabs(utmCard) : ""} - +
${!loading && !unavailable && rows.length ? html` this.#dispatch("view-breakdown", selected)}>View all @@ -187,7 +191,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
- + ${empty ? "" : html`
${!loading && rows.length ? html` this.#dispatch("view-events")}>View all` : ""}
`} @@ -199,7 +203,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem render() { const standardCards = this.cards.filter((card) => card.kind !== "tabbed-breakdown" || card.id !== "utm"); const utmCard = this.cards.find((card): card is Extract => card.kind === "tabbed-breakdown" && card.id === "utm"); - const referrerCard = standardCards.find((card) => card.kind === "breakdown" && card.dimension === "ReferrerHostname"); + const referrerCard = standardCards.find((card) => card.kind === "breakdown" && (card.dimension === "ReferrerHostname" || card.dimension === "Referrer")); const renderCard = (card: DashboardCard) => card === referrerCard ? this.#renderAcquisitionCard(card, utmCard) : this.#renderCard(card); const documentScoped = !standardCards.some((card) => card.kind === "breakdown" && card.dimension === "RequestPath"); const cardsBeforeEvents = documentScoped ? standardCards.slice(0, 1) : standardCards; @@ -207,11 +211,11 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem return html`
${cardsBeforeEvents.map(renderCard)} - ${this.#renderEvents()} + ${this.supportsEvents ? this.#renderEvents() : ""} ${cardsAfterEvents.map(renderCard)} - - - + ${this.supportsFlags ? html` + + ` : ""}
`; } @@ -249,4 +253,4 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-breakdown-grid": VercelAnalyticsBreakdownGridElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-breakdown-grid": WebAnalyticsBreakdownGridElement; } } 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..619db14 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -25,12 +25,13 @@ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); import { dateRangeForPreset } from "./date-range.js"; import { successState } from "./async-state.js"; import { dashboardCards } from "./dashboard-cards.js"; -import type { VercelAnalyticsSummaryElement } from "./analytics-summary.element.js"; -import type { VercelAnalyticsBreakdownGridElement } from "./analytics-breakdown-grid.element.js"; -import type { VercelAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; -import type { VercelAnalyticsDashboardElement } from "./analytics-dashboard.element.js"; -import type { VercelAnalyticsDashboardHeaderElement } from "./analytics-dashboard-header.element.js"; -import type { VercelAnalyticsFlagCardElement } from "./flag-card.element.js"; +import type { WebAnalyticsSummaryElement } from "./analytics-summary.element.js"; +import type { WebAnalyticsBreakdownGridElement } from "./analytics-breakdown-grid.element.js"; +import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; +import type { WebAnalyticsDashboardElement } from "./analytics-dashboard.element.js"; +import type { WebAnalyticsDashboardHeaderElement } from "./analytics-dashboard-header.element.js"; +import type { WebAnalyticsFlagCardElement } from "./flag-card.element.js"; +import type { WebAnalyticsEventDetailsDialogElement } from "./event-details-dialog.element.js"; import "./analytics-summary.element.js"; import "./analytics-breakdown-grid.element.js"; import "./analytics-dashboard.element.js"; @@ -40,7 +41,16 @@ beforeEach(() => { sdk.connections.mockResolvedValue(apiOk({ 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: { dimensions: ["RequestPath", "Route", "ReferrerHostname", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], })); sdk.documentRoutes.mockResolvedValue(apiOk([])); sdk.summary.mockResolvedValue(apiOk({ totals: { visitors: 12, pageViews: 34 }, points: [] })); @@ -57,12 +67,14 @@ afterEach(() => { describe("analytics presentation components", () => { it("links document analytics to the resolved page URL", async () => { - const element = document.createElement("vercel-analytics-dashboard-header") as VercelAnalyticsDashboardHeaderElement; + const element = document.createElement("web-analytics-dashboard-header") as WebAnalyticsDashboardHeaderElement; element.documentScoped = true; element.range = dateRangeForPreset(30); element.siteUrl = "https://example.com"; element.route = { connection: "11111111-1111-1111-1111-111111111111", + provider: "Vercel", + capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, culture: "en-US", hostname: "example.com", path: "/products/example", @@ -93,18 +105,18 @@ describe("analytics presentation components", () => { it("directs first-time users to Web Analytics settings", async () => { sdk.connections.mockResolvedValue(apiOk({ enabled: true, defaultRangeDays: 30, connections: [] })); - const element = document.createElement("vercel-analytics-dashboard") as VercelAnalyticsDashboardElement; + const element = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; document.body.append(element); await vi.waitFor(() => expect(element.shadowRoot?.querySelector("umb-empty-state")?.getAttribute("headline")).toBe("Connect Web Analytics")); const action = element.shadowRoot?.querySelector("uui-button"); - expect(action?.getAttribute("href")).toBe("/umbraco/section/settings/dashboard/vercel-analytics"); + expect(action?.getAttribute("href")).toBe("/umbraco/section/settings/dashboard/web-analytics"); expect(action?.getAttribute("label")).toBe("Open Web Analytics settings"); }); it("emits metric changes from the summary tabs", async () => { - const element = document.createElement("vercel-analytics-summary") as VercelAnalyticsSummaryElement; + const element = document.createElement("web-analytics-summary") as WebAnalyticsSummaryElement; element.range = dateRangeForPreset(30); element.metric = "visitors"; element.report = successState({ totals: { visitors: 12, pageViews: 34 }, points: [] }); @@ -120,7 +132,7 @@ describe("analytics presentation components", () => { }); it("formats summary totals with the active backoffice locale", async () => { - const element = document.createElement("vercel-analytics-summary") as VercelAnalyticsSummaryElement; + const element = document.createElement("web-analytics-summary") as WebAnalyticsSummaryElement; element.lang = "da-DK"; element.range = dateRangeForPreset(30); element.metric = "visitors"; @@ -132,7 +144,7 @@ describe("analytics presentation components", () => { }); it("emits audience changes from the breakdown tabs", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "tabbed-breakdown" && card.id === "audience"); element.audienceDimension = "DeviceType"; element.breakdowns = { @@ -154,7 +166,7 @@ describe("analytics presentation components", () => { }); it("normalizes percentage cards against all visible grouped rows", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "tabbed-breakdown" && card.id === "audience"); element.audienceDimension = "DeviceType"; element.metric = "visitors"; @@ -172,13 +184,13 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const table = element.shadowRoot?.querySelector("vercel-analytics-breakdown-table"); + const table = element.shadowRoot?.querySelector("web-analytics-breakdown-table"); expect(table?.total).toBe(11_339); }); - it("renders referrers as secure external links with favicons for attributed hosts", async () => { - const element = document.createElement("vercel-analytics-breakdown-table") as VercelAnalyticsBreakdownTableElement; - element.dimension = "ReferrerHostname"; + it.each(["ReferrerHostname", "Referrer"] as const)("renders %s rows as secure external links with favicons for attributed hosts", async (dimension) => { + const element = document.createElement("web-analytics-breakdown-table") as WebAnalyticsBreakdownTableElement; + element.dimension = dimension; element.rows = [ { value: "google.com", visitors: 22_304, pageViews: 30_000 }, { value: "Unknown", visitors: 1, pageViews: 1 }, @@ -200,19 +212,19 @@ describe("analytics presentation components", () => { }); it("places events beside referrers in document analytics", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); element.events = successState({ rows: [{ eventName: "Signup", visitors: 12, count: 18 }] }); document.body.append(element); await element.updateComplete; const cards = [...element.shadowRoot?.querySelectorAll("uui-box") ?? []]; - expect(cards[0]?.querySelector("vercel-analytics-breakdown-table")?.headline).toBe("Referrers"); - expect(cards[1]?.querySelector("vercel-analytics-event-table")).not.toBeNull(); + expect(cards[0]?.querySelector("web-analytics-breakdown-table")?.headline).toBe("Referrers"); + expect(cards[1]?.querySelector("web-analytics-event-table")).not.toBeNull(); }); it("gives Flags the same wide span as Events in the overview", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable"); element.events = successState({ rows: [] }); document.body.append(element); @@ -220,14 +232,14 @@ describe("analytics presentation components", () => { const cards = [...element.shadowRoot?.querySelectorAll("uui-box") ?? []]; const flagsCard = cards[cards.length - 1]; - expect(flagsCard?.querySelector("vercel-analytics-flag-card")).not.toBeNull(); + expect(flagsCard?.querySelector("web-analytics-flag-card")).not.toBeNull(); expect(flagsCard?.classList.contains("flags-card")).toBe(true); expect(flagsCard?.classList.contains("wide")).toBe(true); - expect(cards[cards.length - 2]?.querySelector("vercel-analytics-event-table")).not.toBeNull(); + expect(cards[cards.length - 2]?.querySelector("web-analytics-event-table")).not.toBeNull(); }); it("keeps Flags on its own row in document analytics", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); element.events = successState({ rows: [] }); document.body.append(element); @@ -240,7 +252,7 @@ describe("analytics presentation components", () => { }); it("merges valid UTM reports into the referrers card with five parameter tabs", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "available"); element.breakdowns = { ReferrerHostname: successState({ dimension: "ReferrerHostname", rows: [{ value: "google.com", visitors: 8, pageViews: 10 }] }), @@ -267,8 +279,8 @@ describe("analytics presentation components", () => { const parameterTabs = [...element.shadowRoot?.querySelectorAll(".utm-tabs [role=tab]") ?? []]; expect(parameterTabs.map((tab) => tab.textContent?.trim())).toEqual(["Source", "Medium", "Campaign", "Term", "Content"]); - const acquisitionTable = [...element.shadowRoot?.querySelectorAll("vercel-analytics-breakdown-table") ?? []] - .find((table) => table.querySelector(".utm-tabs")) as VercelAnalyticsBreakdownTableElement; + const acquisitionTable = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] + .find((table) => table.querySelector(".utm-tabs")) as WebAnalyticsBreakdownTableElement; await acquisitionTable.updateComplete; const headerRows = acquisitionTable.shadowRoot?.querySelectorAll("thead tr"); expect(headerRows?.[0].lastElementChild?.textContent?.trim()).toBe("Visitors"); @@ -276,11 +288,11 @@ describe("analytics presentation components", () => { expect(headerRows?.[1].firstElementChild?.getAttribute("colspan")).toBe("2"); parameterTabs[4]?.click(); expect((onChange.mock.calls[0][0] as CustomEvent).detail).toEqual({ dimension: "UtmContent" }); - expect(element.shadowRoot?.querySelectorAll("vercel-analytics-breakdown-table").length).toBe(5); + expect(element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table").length).toBe(5); }); it("keeps the UTM tab hidden until a UTM report is valid", async () => { - const element = document.createElement("vercel-analytics-breakdown-grid") as VercelAnalyticsBreakdownGridElement; + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unknown"); element.breakdowns = { ReferrerHostname: successState({ dimension: "ReferrerHostname", rows: [] }), @@ -294,7 +306,7 @@ describe("analytics presentation components", () => { }); it("shows event setup guidance when no custom events have been tracked", async () => { - const element = document.createElement("vercel-analytics-event-table") as HTMLElement & { + const element = document.createElement("web-analytics-event-table") as HTMLElement & { rows: unknown[]; updateComplete: Promise; }; @@ -302,15 +314,70 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const setupLink = element.shadowRoot?.querySelector(".empty a"); expect(element.shadowRoot?.querySelector(".empty strong")?.textContent).toBe("No events"); expect(element.shadowRoot?.querySelector(".empty-icon uui-icon")?.getAttribute("name")).toBe("icon-lightning"); - expect(setupLink?.href).toBe("https://vercel.com/docs/analytics/custom-events"); - expect(setupLink?.rel).toBe("noopener noreferrer"); + expect(element.shadowRoot?.querySelector(".empty a")).toBeNull(); + }); + + it("only offers global event filtering when the provider supports it", async () => { + const element = document.createElement("web-analytics-event-table") as HTMLElement & { + rows: Array<{ eventName: string; visitors: number; count: number }>; + filteringEnabled: boolean; + updateComplete: Promise; + }; + element.rows = [{ eventName: "Read case", visitors: 12, count: 15 }]; + document.body.append(element); + await element.updateComplete; + expect(element.shadowRoot?.querySelector(".filter-action")).toBeNull(); + + element.filteringEnabled = true; + await element.updateComplete; + expect(element.shadowRoot?.querySelector(".filter-action")?.getAttribute("aria-label")).toBe("Filter analytics by Read case event"); + }); + + it("shows a stable empty state when event properties are unavailable", async () => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: vi.fn(), + }); + const element = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + element.eventName = "Read article"; + element.propertiesEnabled = true; + element.details = { + eventName: "Read article", + totals: { count: 9, visitors: 7 }, + properties: [], + }; + document.body.append(element); + await element.updateComplete; + + expect(element.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Read article event"); + expect(element.shadowRoot?.querySelector(".event-totals")).toBeNull(); + expect(element.shadowRoot?.querySelector(".dialog-content")?.classList.contains("no-properties")).toBe(false); + expect(element.shadowRoot?.querySelector("umb-empty-state")?.getAttribute("headline")).toBe("No property data"); + expect(element.shadowRoot?.querySelector(".no-properties-state")?.textContent).toContain("No custom property values were found"); + }); + + it("places event property search above the results table", async () => { + const element = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + element.eventName = "Read article"; + element.propertiesEnabled = true; + element.details = { + eventName: "Read article", + totals: { count: 9, visitors: 7 }, + properties: [{ name: "title", values: [{ value: "Analytics", count: 9, visitors: 7 }] }], + }; + document.body.append(element); + await element.updateComplete; + + const search = element.shadowRoot?.querySelector('uui-input[type="search"]'); + expect(search?.parentElement?.classList.contains("property-controls")).toBe(true); + expect(element.shadowRoot?.querySelector("table uui-input")).toBeNull(); + expect(search?.getAttribute("placeholder")).toBe("Search title"); }); it("drills from flag keys into their values and provides setup guidance when empty", async () => { - const element = document.createElement("vercel-analytics-flag-card") as VercelAnalyticsFlagCardElement; + const element = document.createElement("web-analytics-flag-card") as WebAnalyticsFlagCardElement; element.report = successState({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] }); const onSelect = vi.fn(); element.addEventListener("select-flag", onSelect); @@ -336,13 +403,13 @@ describe("analytics presentation components", () => { }); it("wires a summary interaction through the mounted dashboard controller", async () => { - const dashboard = document.createElement("vercel-analytics-dashboard") as VercelAnalyticsDashboardElement; + const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => { - const summary = dashboard.shadowRoot?.querySelector("vercel-analytics-summary"); + const summary = dashboard.shadowRoot?.querySelector("web-analytics-summary"); expect(summary?.report.status).toBe("success"); }); - const summary = dashboard.shadowRoot?.querySelector("vercel-analytics-summary"); + const summary = dashboard.shadowRoot?.querySelector("web-analytics-summary"); await summary?.updateComplete; summary?.shadowRoot?.querySelector("#metric-pageViews-tab")?.click(); @@ -353,7 +420,7 @@ describe("analytics presentation components", () => { it("clears every active filter from the mounted dashboard and URL", async () => { window.history.replaceState({}, "", "/umbraco/section/analytics?filter=RequestPath%3A%2F&filter=Country%3ADK"); - const dashboard = document.createElement("vercel-analytics-dashboard") as VercelAnalyticsDashboardElement; + const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll(".filter-badge")).toHaveLength(2)); 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 f064d89..90d1a18 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 @@ -7,8 +7,8 @@ import type { AnalyticsDateRange, DatePreset } from "./date-range.js"; import { googleFaviconUrl } from "./favicon.js"; import "./date-range-picker.element.js"; -@customElement("vercel-analytics-dashboard-header") -export class VercelAnalyticsDashboardHeaderElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-dashboard-header") +export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) connections: AnalyticsConnectionSummary[] = []; @property() connection?: string; @property({ attribute: false }) route?: AnalyticsDocumentRoute; @@ -83,9 +83,9 @@ export class VercelAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEl
${!this.documentScoped && this.connections.length > 1 ? html` - + ` : ""} - +
@@ -132,9 +132,9 @@ export class VercelAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEl header { align-items: stretch; } .site-context { flex: 1 1 100%; } .controls { align-items: stretch; inline-size: 100%; margin-inline-start: 0; } - .project-select, vercel-analytics-date-range-picker { box-sizing: border-box; flex: 1 1 100%; inline-size: 100%; max-inline-size: none; } + .project-select, web-analytics-date-range-picker { box-sizing: border-box; flex: 1 1 100%; inline-size: 100%; max-inline-size: none; } } `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-dashboard-header": VercelAnalyticsDashboardHeaderElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-dashboard-header": WebAnalyticsDashboardHeaderElement; } } 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..1cb3ea7 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,19 @@ 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, + eventDetails: true, + eventProperties: true, + globalEventFiltering: false, + flags: true, + breakdownOrdering: false, +}; + describe("AnalyticsDashboardController", () => { it("uses the first connection when no requested or stored connection is valid", async () => { const api = dashboardApi(); @@ -11,8 +21,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 +39,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>>(); @@ -72,6 +82,22 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.summary.status).toBe("success"); }); + it("keeps the provider identity for document-scoped event details", async () => { + const api = dashboardApi(); + api.documentRoutes.mockResolvedValue(ok([{ + ...route("/case", "da-DK"), + provider: "Plausible", + capabilities: { ...fullCapabilities, flags: false, globalEventFiltering: true, breakdownOrdering: true }, + }])); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + + controller.connect("document-id", "da-DK"); + + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + expect(controller.state.provider).toBe("Plausible"); + expect(controller.state.connections).toEqual([]); + }); + it("does not restore a breakdown after its dialog closes during a request", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); @@ -106,6 +132,56 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.selectedEvent).toBeUndefined(); }); + it("reuses property values returned with event details", async () => { + const api = dashboardApi(); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 20, visitors: 10 }] }], + })); + 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.eventPropertyValues).not.toHaveBeenCalled(); + expect(controller.state.selectedEvent?.details.status).toBe("success"); + }); + + it("loads event details without fetching property values when the capability is unavailable", async () => { + const api = dashboardApi(); + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Details only", + provider: "Vercel", + capabilities: { ...fullCapabilities, eventProperties: false }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], + })); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [] }], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + + await controller.selectEvent("Signup"); + controller.searchEventProperty("plan", "Pro"); + + expect(api.eventDetails).toHaveBeenCalledOnce(); + expect(api.eventPropertyValues).not.toHaveBeenCalled(); + expect(controller.state.selectedEvent?.details.status).toBe("success"); + }); + it("atomically clears document and dialog state when scope changes", async () => { const api = dashboardApi(); api.documentRoutes.mockResolvedValue(ok([route("/old", "en-US")])); @@ -205,6 +281,29 @@ describe("AnalyticsDashboardController", () => { expect(target.currentUrl().searchParams.get("audience")).toBe("BrowserName"); }); + it("refreshes only breakdowns when the selected metric changes", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect([ + controller.state.summary.status, + controller.state.events.status, + controller.state.flags.status, + ]).toEqual(["success", "success", "success"])); + api.summary.mockClear(); + api.events.mockClear(); + api.flags.mockClear(); + api.breakdown.mockClear(); + + controller.setMetric("pageViews"); + + await vi.waitFor(() => expect(api.breakdown).toHaveBeenCalled()); + expect(api.breakdown.mock.calls[0]?.[0]?.query).not.toHaveProperty("orderBy"); + expect(api.summary).not.toHaveBeenCalled(); + expect(api.events).not.toHaveBeenCalled(); + expect(api.flags).not.toHaveBeenCalled(); + }); + it("uses only UTM Source to detect Plus capability", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); @@ -308,6 +407,127 @@ describe("AnalyticsDashboardController", () => { expect(api.summary).not.toHaveBeenCalled(); }); + it("uses Plausible capabilities for event details and global event filtering", 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", "Referrer", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "EventName"], + events: true, + eventDetails: true, + eventProperties: true, + globalEventFiltering: true, + flags: false, + breakdownOrdering: true, + }, + 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")); + api.summary.mockClear(); + controller.toggleFilter("EventName", "Signup"); + await vi.waitFor(() => expect(api.summary).toHaveBeenCalled()); + expect(api.summary.mock.calls[0]?.[0]?.query?.filter).toEqual(["EventName:Signup"]); + await controller.selectEvent("Signup"); + + expect(api.events).toHaveBeenCalled(); + expect(api.flags).not.toHaveBeenCalled(); + expect(api.eventDetails).toHaveBeenCalled(); + expect(api.eventPropertyValues).not.toHaveBeenCalled(); + expect(controller.cards().some((card) => card.kind === "tabbed-breakdown" && card.id === "utm")).toBe(true); + }); + + it("does not create a global event filter for providers that do not support it", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.summary.mockClear(); + + controller.toggleFilter("EventName", "Signup"); + + expect(controller.state.filters).toEqual([]); + expect(api.summary).not.toHaveBeenCalled(); + }); + + it("removes unsupported filters when switching providers", async () => { + const api = dashboardApi(); + const plausibleCapabilities: AnalyticsCapabilities = { + dimensions: fullCapabilities.dimensions + .filter((dimension) => dimension !== "Route" && dimension !== "ReferrerHostname") + .concat("Referrer"), + events: true, + eventDetails: true, + eventProperties: true, + globalEventFiltering: true, + flags: false, + breakdownOrdering: true, + }; + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [ + { key: "11111111-1111-1111-1111-111111111111", displayName: "Vercel", provider: "Vercel", capabilities: fullCapabilities, isDefault: true, isConfigured: true, baseUrl: "https://vercel.example.com", warnings: [] }, + { key: "22222222-2222-2222-2222-222222222222", displayName: "Plausible", provider: "Plausible", capabilities: plausibleCapabilities, isDefault: false, isConfigured: true, baseUrl: "https://plausible.example.com", warnings: [] }, + ], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + controller.toggleFilter("Route", "/articles/[slug]"); + + controller.setConnection("22222222-2222-2222-2222-222222222222"); + + expect(controller.state.filters).toEqual([]); + await vi.waitFor(() => expect(api.summary).toHaveBeenCalledTimes(3)); + expect(api.summary.mock.calls[api.summary.mock.calls.length - 1]?.[0]?.query?.filter).toBeUndefined(); + }); + + it("normalizes restored filters after resolving the selected provider", async () => { + const api = dashboardApi(); + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Plausible", + provider: "Plausible", + capabilities: { + ...fullCapabilities, + dimensions: fullCapabilities.dimensions + .filter((dimension) => dimension !== "Route" && dimension !== "ReferrerHostname") + .concat("Referrer"), + }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], + })); + const controller = new AnalyticsDashboardController( + vi.fn(), + api, + environment("https://cms.example.com/umbraco/section/analytics?connection=11111111-1111-1111-1111-111111111111&filter=Route%3A%2Farticles%2F%5Bslug%5D"), + ); + + controller.connect(); + + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + expect(controller.state.filters).toEqual([]); + expect(api.summary.mock.calls[0]?.[0]?.query?.filter).toBeUndefined(); + }); + }); function dashboardApi() { @@ -315,7 +535,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,11 +548,11 @@ 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 { - let url = new URL("https://cms.example.com/umbraco/section/analytics"); +function environment(initialUrl = "https://cms.example.com/umbraco/section/analytics"): DashboardEnvironment { + let url = new URL(initialUrl); return { currentUrl: () => new URL(url), replaceUrl: (next) => { url = new URL(next); }, 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..61efa52 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, @@ -8,6 +9,7 @@ import type { AnalyticsEventRow, AnalyticsEventsReport, AnalyticsFlagsReport, + AnalyticsProvider, AnalyticsSummary, } from "../api/types.gen.js"; import { dashboardApi, type DashboardApi } from "./dashboard-api.js"; @@ -24,12 +26,13 @@ import { type DashboardMetric, type UtmDimension, } from "./dashboard-url-state.js"; -import { loadDashboardBreakdown, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; +import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; import { visibleEventRows } from "./event-rows.js"; import { reportErrorMessage } from "./report-error.js"; import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; 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 }; export type ExpandedBreakdown = { @@ -49,7 +52,9 @@ export type SelectedEvent = { export type DashboardState = { connections: AnalyticsConnectionSummary[]; connection?: string; + provider?: AnalyticsProvider; route?: AnalyticsDocumentRoute; + capabilities?: AnalyticsCapabilities; range: AnalyticsDateRange; preset: DatePreset; summary: AsyncState; @@ -151,6 +156,7 @@ export class AnalyticsDashboardController { this.#eventPropertyRequest.cancel(); this.#set({ route: undefined, + provider: undefined, configurationError: undefined, summary: loadingState(), breakdowns: {}, @@ -195,13 +201,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 +221,18 @@ export class AnalyticsDashboardController { signal, (update) => this.#applyReportUpdate(update), this.#api, + capabilities, + this.state.metric, )); 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 }); @@ -235,8 +245,14 @@ export class AnalyticsDashboardController { // 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. + const selectedConnection = this.state.connections.find(({ key }) => key === connection); + const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; + const selection = normalizeDashboardSelection(this.state, capabilities); this.#set({ connection, + provider: selectedConnection?.provider, + capabilities, + ...selection, acquisitionView: "referrers", summary: loadingState(), breakdowns: {}, @@ -253,7 +269,16 @@ export class AnalyticsDashboardController { void this.loadReports(); } - setMetric(metric: DashboardMetric): void { this.#set({ metric }); this.#syncUrlState(); } + setMetric(metric: DashboardMetric): void { + if (this.state.metric === metric) return; + this.#set({ metric }); + this.#syncUrlState(); + const capabilities = this.#capabilities(); + const nonBreakdownReportPending = this.state.summary.status === "loading" + || (capabilities.events && this.state.events.status === "loading") + || (capabilities.flags && this.state.flags.status === "loading"); + void (nonBreakdownReportPending ? this.loadReports() : this.#loadBreakdowns()); + } setAudienceDimension(audienceDimension: AudienceDimension): void { this.#set({ audienceDimension }); this.#syncUrlState(); } setAcquisitionView(acquisitionView: AcquisitionView): void { if (acquisitionView === "utm" && this.state.utmCapability !== "available") return; @@ -264,6 +289,7 @@ export class AnalyticsDashboardController { } setUtmDimension(utmDimension: UtmDimension): void { + if (!supportsDimension(this.#capabilities(), utmDimension)) return; const changed = utmDimension !== this.state.utmDimension; if (changed) this.#utmRequest.cancel(); this.#set({ utmDimension }); @@ -272,7 +298,8 @@ export class AnalyticsDashboardController { } toggleFilter(dimension: AnalyticsDimension | undefined, value: string): void { - if (!dimension || !value) return; + if (!dimension || !value || !supportsDimension(this.#capabilities(), dimension)) return; + if (dimension === "EventName" && !this.#capabilities().globalEventFiltering) return; const active = this.state.filters.some((filter) => filter.dimension === dimension && filter.value === value); const filters = active ? this.state.filters.filter((filter) => filter.dimension !== dimension) @@ -291,6 +318,7 @@ export class AnalyticsDashboardController { clearFilters(): void { this.#set({ filters: [] }); this.#syncUrlState(); void this.loadReports(); } async openBreakdown(dimension: AnalyticsDimension, headline: string, search = "", debounce = false): Promise { + if (!supportsDimension(this.#capabilities(), dimension)) return; const connection = this.state.connection; if (!connection) return; const previous = this.state.expandedBreakdown?.dimension === dimension ? this.state.expandedBreakdown.report : undefined; @@ -308,7 +336,7 @@ export class AnalyticsDashboardController { } const { data, error, response } = result.value; this.#set({ expandedBreakdown: { dimension, headline, report: error - ? errorState(apiErrorMessage(error, response.status), previous) + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(data?.rows ?? []) } }); } @@ -322,6 +350,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; @@ -334,13 +363,14 @@ export class AnalyticsDashboardController { if (result.status === "error") { this.#set({ expandedEvents: errorState(reportErrorMessage(result.error), previous) }); return; } const { data, error, response } = result.value; this.#set({ expandedEvents: error - ? errorState(apiErrorMessage(error, response.status), previous) + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(visibleEventRows(data?.rows ?? [])) }); } 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; @@ -352,23 +382,27 @@ export class AnalyticsDashboardController { if (result.status === "error") { this.#set({ selectedFlag: errorState(reportErrorMessage(result.error), previous) }); return; } const { data, error, response } = result.value; this.#set({ selectedFlag: error || !data - ? errorState(apiErrorMessage(error, response.status), previous) + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(data) }); } 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().eventDetails) return; + this.closeEvents(); + await this.#loadEventDetails(eventName); + } toggleEventPropertyFilter(property: string, value: string): void { const selected = this.state.selectedEvent; - if (!selected) return; + if (!selected || !this.#capabilities().eventProperties) return; const active = selected.eventProperty === property && selected.eventValue === value; void this.#loadEventDetails(selected.eventName, active ? undefined : property, active ? undefined : value); } searchEventProperty(propertyName: string, search: string): void { - if (this.state.selectedEvent) void this.#loadEventPropertyValues(propertyName, search.trim(), true); + if (this.state.selectedEvent && this.#capabilities().eventProperties) void this.#loadEventPropertyValues(propertyName, search.trim(), true); } closeEventDetails(): void { @@ -394,14 +428,15 @@ export class AnalyticsDashboardController { this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); return; } - this.#set({ route, connection: route.connection }); + const selection = normalizeDashboardSelection(this.state, route.capabilities); + this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); } else { const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); if (result.status === "cancelled" || result.status === "stale") return; 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) { @@ -416,9 +451,16 @@ export class AnalyticsDashboardController { const stored = this.#environment.getStoredConnection(); const requested = data.connections.some(({ key }) => key === this.state.connection) ? this.state.connection : undefined; const storedValid = data.connections.some(({ key }) => key === stored) ? stored ?? undefined : undefined; + const connection = requested ?? storedValid ?? data.connections[0]?.key; + const selectedConnection = data.connections.find(({ key }) => key === connection); + const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; + const selection = normalizeDashboardSelection(this.state, capabilities); this.#set({ connections: data.connections, - connection: requested ?? storedValid ?? data.connections[0]?.key, + connection, + provider: selectedConnection?.provider, + capabilities, + ...selection, preset, range, }); @@ -442,6 +484,27 @@ export class AnalyticsDashboardController { } } + async #loadBreakdowns(): Promise { + const connection = this.state.connection; + if (!connection) return; + this.#closeDialogs(); + this.#utmRequest.cancel(); + const { dimensions } = this.#dashboardReportPlan(); + this.#set({ + breakdowns: Object.fromEntries(dimensions.map((dimension) => [dimension, loadingState(this.state.breakdowns[dimension])])), + }); + const result = await this.#reportRequest.run((signal) => loadDashboardBreakdowns( + this.#reportQuery(connection, this.#visitFilterQuery()), + dimensions, + signal, + (update) => this.#applyReportUpdate(update), + this.#api, + this.state.metric, + this.#capabilities().breakdownOrdering, + )); + if (result.status === "error") this.#failLoadingBreakdowns(reportErrorMessage(result.error), dimensions); + } + #failLoadingReports(message: string, dimensions: ReadonlyArray): void { this.#set({ summary: errorState(message, this.state.summary), @@ -451,13 +514,37 @@ export class AnalyticsDashboardController { }); } + #failLoadingBreakdowns(message: string, dimensions: ReadonlyArray): void { + this.#set({ + breakdowns: Object.fromEntries(dimensions.map((dimension) => [dimension, errorState(message, this.state.breakdowns[dimension])])), + }); + } + #dashboardReportPlan(utmCapability = this.state.utmCapability): DashboardReportPlan { - return dashboardReportPlan( + const capabilities = this.#capabilities(); + const referrerDimension = capabilities.dimensions.includes("ReferrerHostname") ? "ReferrerHostname" : "Referrer"; + const plan = dashboardReportPlan( Boolean(this.#documentId), utmCapability, this.state.acquisitionView, this.state.utmDimension, + referrerDimension, ); + const supported = new Set(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 { + return this.state.capabilities ?? unavailableCapabilities; } #ensureUtmBreakdown(dimension: UtmDimension): void { @@ -476,6 +563,8 @@ export class AnalyticsDashboardController { dimension, signal, this.#api, + this.state.metric, + this.#capabilities().breakdownOrdering, )); if (result.status === "cancelled" || result.status === "stale" || this.state.connection !== connection @@ -504,18 +593,20 @@ export class AnalyticsDashboardController { } const { data, error, response } = result.value; if (error || !data) { - this.#set({ selectedEvent: { ...this.state.selectedEvent, details: errorState(apiErrorMessage(error, response.status), previous) } }); + this.#set({ selectedEvent: { ...this.state.selectedEvent, details: errorState(apiErrorMessage(error, response?.status ?? 0), previous) } }); return; } this.#set({ selectedEvent: { ...this.state.selectedEvent, details: successState(data) } }); - const firstProperty = data.properties[0]?.name; - if (firstProperty) void this.#loadEventPropertyValues(firstProperty, ""); + const firstProperty = data.properties[0]; + if (this.#capabilities().eventProperties && firstProperty && !firstProperty.values.length) { + void this.#loadEventPropertyValues(firstProperty.name, ""); + } } async #loadEventPropertyValues(propertyName: string, search: string, debounce = false): Promise { const connection = this.state.connection; const selected = this.state.selectedEvent; - if (!connection || !selected) return; + if (!connection || !selected || !this.#capabilities().eventProperties) return; const previous = selected.property; this.#set({ selectedEvent: { ...selected, propertyName, propertySearch: search, property: loadingState(previous) } }); const run = (signal: AbortSignal) => this.#api.eventPropertyValues({ @@ -539,7 +630,7 @@ export class AnalyticsDashboardController { } const { data, error, response } = result.value; this.#set({ selectedEvent: { ...current, property: error || !data - ? errorState(apiErrorMessage(error, response.status), previous) + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(data) } }); } @@ -559,7 +650,9 @@ export class AnalyticsDashboardController { } #visitFilterQuery(): { filter?: string[] } { - return this.#serializedFilters(this.state.filters.filter(({ dimension }) => dimension !== "EventName")); + return this.#serializedFilters(this.#capabilities().globalEventFiltering + ? this.state.filters + : this.state.filters.filter(({ dimension }) => dimension !== "EventName")); } #eventListFilterQuery(): { filter?: string[] } { return this.#serializedFilters(this.state.filters); } 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..a300dca 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -15,8 +15,8 @@ import "./breakdown-dialog.element.js"; import "./event-dialog.element.js"; import "./event-details-dialog.element.js"; -@customElement("vercel-analytics-dashboard") -export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-dashboard") +export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) documentId?: string; @property() culture?: string; @state() private _revision = 0; @@ -82,9 +82,9 @@ 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.

Open settings @@ -95,9 +95,10 @@ export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) const expanded = state.expandedBreakdown; const expandedEvents = state.expandedEvents; const selected = state.selectedEvent; + const capabilities = state.capabilities; return html`
) => this.#controller.toggleFilter(event.detail.dimension, event.detail.value)}> - ) => this.#controller.setConnection(event.detail.connection)} - @analytics-date-range-change=${(event: CustomEvent) => this.#controller.setDateRange(event.detail.preset, event.detail.range)}> + @analytics-date-range-change=${(event: CustomEvent) => this.#controller.setDateRange(event.detail.preset, event.detail.range)}> ${this.#renderFilters(state.filters)} - ) => this.#controller.setMetric(event.detail.metric)} - @retry-summary=${() => this.#controller.loadReports()}> - this.#controller.loadReports()}> + ) => 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)} @@ -134,9 +139,9 @@ export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) @retry-reports=${() => this.#controller.loadReports()} @audience-change=${(event: CustomEvent<{ dimension: AudienceDimension }>) => this.#controller.setAudienceDimension(event.detail.dimension)} @acquisition-change=${(event: CustomEvent<{ view: AcquisitionView }>) => this.#controller.setAcquisitionView(event.detail.view)} - @utm-change=${(event: CustomEvent<{ dimension: UtmDimension }>) => this.#controller.setUtmDimension(event.detail.dimension)}> + @utm-change=${(event: CustomEvent<{ dimension: UtmDimension }>) => this.#controller.setUtmDimension(event.detail.dimension)}> ${expanded ? html` - ) => this.#controller.searchBreakdown(event.detail.search)} - @close-breakdown=${() => this.#controller.closeBreakdown()}> + @close-breakdown=${() => this.#controller.closeBreakdown()}> ` : ""} ${expandedEvents ? html` - ) => this.#controller.openEvents(event.detail.search, true)} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} - @close-events=${() => this.#controller.closeEvents()}> + @close-events=${() => this.#controller.closeEvents()}> ` : ""} - ${selected ? html` - ) => this.#controller.searchEventProperty(event.detail.propertyName, event.detail.search)} @toggle-event-property-filter=${(event: CustomEvent<{ property: string; value: string }>) => this.#controller.toggleEventPropertyFilter(event.detail.property, event.detail.value)} - @close-event-details=${() => this.#controller.closeEventDetails()}> + @close-event-details=${() => this.#controller.closeEventDetails()}> ` : ""}
`; @@ -182,4 +190,4 @@ export class VercelAnalyticsDashboardElement extends UmbElementMixin(LitElement) static styles = analyticsDashboardStyles; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-dashboard": VercelAnalyticsDashboardElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-dashboard": WebAnalyticsDashboardElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts index ec47c8b..373c5b7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts @@ -8,8 +8,8 @@ import type { DashboardMetric } from "./dashboard-url-state.js"; import { stateData, type AsyncState } from "./async-state.js"; import "./history-chart.element.js"; -@customElement("vercel-analytics-summary") -export class VercelAnalyticsSummaryElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-summary") +export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) report: AsyncState = { status: "loading" }; @property({ attribute: false }) range!: AnalyticsDateRange; @property() metric: DashboardMetric = "visitors"; @@ -114,7 +114,7 @@ export class VercelAnalyticsSummaryElement extends UmbElementMixin(LitElement) { Loading traffic summary and history ` : stateData(this.report)?.points.length - ? html`` + ? html`` : html`

No traffic was recorded in this period.

`}
@@ -124,11 +124,11 @@ export class VercelAnalyticsSummaryElement extends UmbElementMixin(LitElement) { static styles = [UmbTextStyles, css` :host { display: block; inline-size: 100%; min-inline-size: 0; } .history, .summary-error { --uui-box-default-padding: 0; margin-bottom: var(--uui-size-layout-1); overflow: hidden; } - .history { --vercel-analytics-chart-color: oklch(51.51% .2399 257.85); } + .history { --web-analytics-chart-color: oklch(51.51% .2399 257.85); } .metric-tabs { background: var(--uui-color-surface-alt); border-bottom: 1px solid var(--uui-color-border); display: flex; flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin; } .metric-tab { --metric-font-size: clamp(2rem, 3cqi, 3rem); appearance: none; background: transparent; border: 0; border-bottom: 3px solid transparent; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; inline-size: max-content; min-block-size: clamp(6.5rem, 10cqi, 7.75rem); min-inline-size: 18rem; padding: clamp(var(--uui-size-space-4), 2cqi, var(--uui-size-space-5)) var(--uui-size-space-5); text-align: left; transition: background-color 160ms ease-out, color 160ms ease-out; } .metric-tab:last-child { border-inline-end: 1px solid var(--uui-color-border); } - .metric-tab[aria-selected="true"] { background: var(--uui-color-surface); border-bottom-color: var(--vercel-analytics-chart-color); color: var(--uui-color-text); } + .metric-tab[aria-selected="true"] { background: var(--uui-color-surface); border-bottom-color: var(--web-analytics-chart-color); color: var(--uui-color-text); } .metric-tab[aria-selected="false"]:hover { background: color-mix(in srgb, var(--uui-color-interactive) 7%, var(--uui-color-surface)); } .metric-tab[aria-selected="false"]:active { background: color-mix(in srgb, var(--uui-color-interactive) 11%, var(--uui-color-surface)); } .metric-tab:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } @@ -163,4 +163,4 @@ export class VercelAnalyticsSummaryElement extends UmbElementMixin(LitElement) { `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-summary": VercelAnalyticsSummaryElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-summary": WebAnalyticsSummaryElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index 65373c4..a7b6c3f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -14,8 +14,8 @@ import { breakdownMetricTotal, type TrafficMetric } from "./breakdown-rows.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import "./breakdown-table.element.js"; -@customElement("vercel-analytics-breakdown-dialog") -export class VercelAnalyticsBreakdownDialogElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-breakdown-dialog") +export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitElement) { @property() headline = "Breakdown"; @property() loading = false; @property() unavailable?: string; @@ -72,7 +72,7 @@ export class VercelAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEl ? html`

Try a different search.

` : ""} ${(this.loading || (!this.unavailable && (!this._search || this.rows.length > 0))) ? html` - + .linkValues=${this.linkValues}> ` : ""}
Close @@ -119,6 +119,6 @@ export class VercelAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEl declare global { interface HTMLElementTagNameMap { - "vercel-analytics-breakdown-dialog": VercelAnalyticsBreakdownDialogElement; + "web-analytics-breakdown-dialog": WebAnalyticsBreakdownDialogElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts index 1d5b640..a5e1d69 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -16,8 +16,8 @@ import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./coun import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { googleFaviconUrl } from "./favicon.js"; -@customElement("vercel-analytics-breakdown-table") -export class VercelAnalyticsBreakdownTableElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-breakdown-table") +export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElement) { @property() headline = "Breakdown"; @property() unavailable?: string; @property() baseUrl?: string; @@ -58,13 +58,14 @@ export class VercelAnalyticsBreakdownTableElement extends UmbElementMixin(LitEle ${this.headline} ${this.#renderHeading()} ${rows.map((row, index) => { - const href = this.dimension === "ReferrerHostname" + const isReferrer = this.dimension === "ReferrerHostname" || this.dimension === "Referrer"; + const href = isReferrer ? referrerExternalHref(row.value) : this.linkValues ? analyticsRowHref(this.baseUrl, row.value) : undefined; const countryCode = this.dimension === "Country" ? normalizeCountryCode(row.value) : undefined; - const faviconUrl = this.dimension === "ReferrerHostname" && href ? googleFaviconUrl(row.value) : undefined; + const faviconUrl = isReferrer && href ? googleFaviconUrl(row.value) : undefined; const displayValue = countryCode ? countryDisplayName(countryCode, navigator.languages) : breakdownDisplayValue(row.value, this.dimension); @@ -256,6 +257,6 @@ export class VercelAnalyticsBreakdownTableElement extends UmbElementMixin(LitEle declare global { interface HTMLElementTagNameMap { - "vercel-analytics-breakdown-table": VercelAnalyticsBreakdownTableElement; + "web-analytics-breakdown-table": WebAnalyticsBreakdownTableElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-api.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-api.ts index b9cb164..692e4ec 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-api.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-api.ts @@ -11,7 +11,7 @@ import type { FlagsData, } from "../api/types.gen.js"; -type ApiResponse = Promise<{ data?: T; error?: unknown; response: Response }>; +type ApiResponse = Promise<{ data?: T; error?: unknown; response?: Response }>; type FlagsOptions = { query?: FlagsData["query"]; signal?: AbortSignal }; export type DashboardApi = { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts new file mode 100644 index 0000000..47050ab --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts @@ -0,0 +1,51 @@ +import type { AnalyticsCapabilities, AnalyticsDimension } from "../api/types.gen.js"; +import type { AnalyticsFilter, AudienceDimension, UtmDimension } from "./dashboard-url-state.js"; +import type { AcquisitionView } from "./dashboard-cards.js"; + +export const unavailableCapabilities: AnalyticsCapabilities = { + dimensions: [], + events: false, + eventDetails: false, + eventProperties: false, + globalEventFiltering: false, + flags: false, + breakdownOrdering: false, +}; + +type DashboardSelection = { + audienceDimension: AudienceDimension; + acquisitionView: AcquisitionView; + utmDimension: UtmDimension; + filters: AnalyticsFilter[]; +}; + +const audienceDimensions: ReadonlyArray = ["DeviceType", "BrowserName"]; +const utmDimensions: ReadonlyArray = ["UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent"]; + +export function normalizeDashboardSelection( + selection: DashboardSelection, + capabilities: AnalyticsCapabilities, +): DashboardSelection { + const supported = new Set(capabilities.dimensions); + const audienceDimension = supported.has(selection.audienceDimension) + ? selection.audienceDimension + : audienceDimensions.find((dimension) => supported.has(dimension)) ?? selection.audienceDimension; + const utmDimension = supported.has(selection.utmDimension) + ? selection.utmDimension + : utmDimensions.find((dimension) => supported.has(dimension)) ?? "UtmSource"; + const supportsUtm = utmDimensions.some((dimension) => supported.has(dimension)); + return { + audienceDimension, + acquisitionView: supportsUtm ? selection.acquisitionView : "referrers", + utmDimension, + filters: selection.filters.filter(({ dimension }) => + supported.has(dimension) && (dimension !== "EventName" || capabilities.globalEventFiltering)), + }; +} + +export function supportsDimension( + capabilities: AnalyticsCapabilities, + dimension: AnalyticsDimension, +): boolean { + return capabilities.dimensions.includes(dimension); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts index dac7461..5ded80d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts @@ -44,7 +44,6 @@ export const UTM_OPTIONS: ReadonlyArray> = [ ]; const SHARED_CARDS: ReadonlyArray = [ - { kind: "breakdown", dimension: "ReferrerHostname", headline: "Referrers", span: "wide" }, { kind: "breakdown", dimension: "Country", headline: "Countries", span: "normal" }, { kind: "tabbed-breakdown", id: "audience", options: AUDIENCE_OPTIONS, reportLoading: "eager", span: "normal", planLimited: false }, { kind: "breakdown", dimension: "OsName", headline: "Operating systems", span: "normal" }, @@ -59,9 +58,14 @@ const UTM_CARD: DashboardCard = { planLimited: true, }; -export function dashboardCards(documentScoped: boolean, utmCapability: UtmCapability): ReadonlyArray { +export function dashboardCards( + documentScoped: boolean, + utmCapability: UtmCapability, + referrerDimension: "ReferrerHostname" | "Referrer" = "ReferrerHostname", +): ReadonlyArray { const cards: DashboardCard[] = [ ...(documentScoped ? [] : [{ kind: "breakdown" as const, dimension: "RequestPath" as const, headline: "Pages", span: "wide" as const }]), + { kind: "breakdown", dimension: referrerDimension, headline: "Referrers", span: "wide" }, ...SHARED_CARDS, ]; if (utmCapability === "available") cards.push(UTM_CARD); @@ -73,8 +77,9 @@ export function dashboardReportPlan( utmCapability: UtmCapability, acquisitionView: AcquisitionView, utmDimension: UtmDimension, + referrerDimension: "ReferrerHostname" | "Referrer" = "ReferrerHostname", ): DashboardReportPlan { - const cards = dashboardCards(documentScoped, utmCapability); + const cards = dashboardCards(documentScoped, utmCapability, referrerDimension); const dimensions = cards.flatMap((card) => card.kind === "breakdown" ? [card.dimension] : card.reportLoading === "eager" ? card.options.map(({ dimension }) => dimension) : []); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.test.ts index 625e3eb..3e16a14 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sdk = vi.hoisted(() => ({ summary: vi.fn(), events: vi.fn(), flags: vi.fn(), breakdown: vi.fn() })); vi.mock("../api/sdk.gen.js", () => ({ WebAnalyticsService: sdk })); -import { loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; +import { loadDashboardBreakdowns, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; const query = { connection: "main", from: "2026-06-01", to: "2026-06-30", interval: "Day" } as DashboardReportQuery; @@ -86,6 +86,65 @@ describe("loadDashboardReports", () => { expect(sdk.events).toHaveBeenCalledWith(expect.objectContaining({ query: { ...eventQuery, limit: 20 } })); }); + it("orders breakdowns by the selected dashboard metric", async () => { + sdk.summary.mockResolvedValue(ok({ totals: { visitors: 1, pageViews: 2 }, points: [] })); + sdk.events.mockResolvedValue(ok({ rows: [] })); + sdk.flags.mockResolvedValue(ok({ rows: [] })); + sdk.breakdown.mockResolvedValue(ok({ rows: [] })); + + await loadDashboardReports( + query, + query, + ["RequestPath"], + new AbortController().signal, + () => undefined, + sdk, + { events: true, flags: true, breakdownOrdering: true }, + "pageViews", + ); + + expect(sdk.breakdown).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ orderBy: "PageViews" }), + })); + }); + + it("omits selectable ordering for providers that use their own breakdown ranking", async () => { + sdk.breakdown.mockResolvedValue(ok({ rows: [] })); + + await loadDashboardBreakdowns( + query, + ["RequestPath"], + new AbortController().signal, + () => undefined, + sdk, + "pageViews", + false, + ); + + expect(sdk.breakdown.mock.calls[0]?.[0]?.query).not.toHaveProperty("orderBy"); + }); + + it("loads only breakdown reports for a metric refresh", async () => { + sdk.breakdown.mockResolvedValue(ok({ rows: [] })); + + await loadDashboardBreakdowns( + query, + ["RequestPath", "Country"], + new AbortController().signal, + () => undefined, + sdk, + "pageViews", + ); + + expect(sdk.breakdown).toHaveBeenCalledTimes(2); + expect(sdk.breakdown).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ orderBy: "PageViews" }), + })); + expect(sdk.summary).not.toHaveBeenCalled(); + expect(sdk.events).not.toHaveBeenCalled(); + expect(sdk.flags).not.toHaveBeenCalled(); + }); + it("preserves stable upstream problem codes when adding HTTP status", async () => { sdk.summary.mockResolvedValue(problem({ code: "invalid_credentials" }, 502)); sdk.events.mockResolvedValue(ok({ rows: [] })); @@ -95,7 +154,7 @@ describe("loadDashboardReports", () => { await loadDashboardReports(query, query, ["Country"], new AbortController().signal, (update) => updates.push(update)); - expect(updates.find((update) => update.panel === "summary")).toMatchObject({ status: "error", error: expect.stringContaining("access token") }); + expect(updates.find((update) => update.panel === "summary")).toMatchObject({ status: "error", error: expect.stringContaining("configured analytics credentials") }); }); it("treats a successful response without data as an explicit error", async () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.ts index 76047fd..a22dbe4 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-report-loader.ts @@ -3,11 +3,12 @@ import { dashboardApi, type DashboardApi } from "./dashboard-api.js"; import { reportErrorMessage } from "./report-error.js"; import { settleRequest, type SettledRequestResult } from "./request-coordinator.js"; import { isUtmDimension } from "./utm-capability.js"; +import type { DashboardMetric } from "./dashboard-url-state.js"; type SummaryOptions = NonNullable[0]>; export type DashboardReportQuery = NonNullable; type LoadedReport = { status: "success"; data: T } | { status: "error"; error: string }; -type SdkResult = { data?: T; error?: unknown; response: Response }; +type SdkResult = { data?: T; error?: unknown; response?: Response }; type ReportApi = Pick; type BreakdownApi = Pick; const maximumConcurrentDashboardReports = 4; @@ -22,6 +23,24 @@ export type LoadedDashboardBreakdown = { responseStatus?: number; }; +export async function loadDashboardBreakdowns( + query: DashboardReportQuery, + dimensions: ReadonlyArray, + signal: AbortSignal, + onUpdate: (update: Extract) => void, + api: BreakdownApi = dashboardApi, + metric: DashboardMetric = "visitors", + breakdownOrdering = true, +): Promise { + const publish = (update: Extract) => { + if (!signal.aborted) onUpdate(update); + }; + await runWithConcurrency( + createBreakdownTasks(query, dimensions, signal, api, metric, breakdownOrdering, ({ update }) => publish(update)), + maximumConcurrentDashboardReports, + ); +} + export async function loadDashboardReports( visitQuery: DashboardReportQuery, eventQuery: DashboardReportQuery, @@ -29,6 +48,8 @@ export async function loadDashboardReports( signal: AbortSignal, onUpdate: (update: DashboardReportUpdate) => void, api: ReportApi = dashboardApi, + capabilities: { events: boolean; flags: boolean; breakdownOrdering: boolean } = { events: true, flags: true, breakdownOrdering: true }, + metric: DashboardMetric = "visitors", ): Promise { let baselineSucceeded = false; let utmSucceeded = false; @@ -41,15 +62,14 @@ 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)) { + })] : []), + ...createBreakdownTasks(visitQuery, dimensions, signal, api, metric, capabilities.breakdownOrdering, ({ update, responseStatus }) => { + if (isUtmDimension(update.dimension)) { if (update.status === "success") utmSucceeded = true; else if (responseStatus !== undefined) utmStatuses.push(responseStatus); } else if (update.status === "success") baselineSucceeded = true; @@ -61,18 +81,37 @@ export async function loadDashboardReports( return { baselineSucceeded, utmSucceeded, utmStatuses }; } +function createBreakdownTasks( + query: DashboardReportQuery, + dimensions: ReadonlyArray, + signal: AbortSignal, + api: BreakdownApi, + metric: DashboardMetric, + breakdownOrdering: boolean, + onLoaded: (loaded: LoadedDashboardBreakdown) => void, +): ReadonlyArray<() => Promise> { + return dimensions.map((dimension) => async () => { + onLoaded(await loadDashboardBreakdown(query, dimension, signal, api, metric, breakdownOrdering)); + }); +} + export async function loadDashboardBreakdown( query: DashboardReportQuery, dimension: AnalyticsDimension, signal: AbortSignal, api: BreakdownApi = dashboardApi, + metric: DashboardMetric = "visitors", + breakdownOrdering = true, ): Promise { + const orderBy = breakdownOrdering ? (metric === "pageViews" ? "PageViews" : "Visitors") : undefined; const result = await settleRequest(api.breakdown({ - path: { dimension }, query: { ...query, limit: 11 }, signal, + path: { dimension }, + query: { ...query, limit: 11, ...(orderBy ? { orderBy } : {}) }, + signal, })); return { update: { panel: "breakdown", dimension, ...toLoadedReport(result) }, - responseStatus: result.status === "success" ? result.value.response.status : undefined, + responseStatus: result.status === "success" ? result.value.response?.status : undefined, }; } @@ -91,7 +130,7 @@ async function runWithConcurrency(tasks: ReadonlyArray<() => Promise>, max function toLoadedReport(result: SettledRequestResult>): LoadedReport { if (result.status === "error") return { status: "error", error: reportErrorMessage(result.error) }; const { data, error, response } = result.value; - if (error) return { status: "error", error: reportErrorMessage(withStatus(error, response.status)) }; + if (error) return { status: "error", error: reportErrorMessage(withStatus(error, response?.status ?? 0)) }; return data == null ? { status: "error", error: "Analytics returned an empty response." } : { status: "success", data }; 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 885b04e..18a7ec7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts @@ -17,7 +17,7 @@ export type DashboardUrlState = { }; const DIMENSIONS = new Set([ - "RequestPath", "Route", "ReferrerHostname", "Country", "DeviceType", + "RequestPath", "Route", "ReferrerHostname", "Referrer", "Country", "DeviceType", "BrowserName", "OsName", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "EventName", ]); const PRESETS = new Set([1, 7, 30, 90, 365]); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.test.ts index e7186fb..f863605 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.test.ts @@ -7,7 +7,7 @@ vi.mock("@umbraco-cms/backoffice/element-api", () => ({ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); import { dateRangeForPreset, normalizeCustomRange } from "./date-range.js"; -import type { VercelAnalyticsDateRangePickerElement } from "./date-range-picker.element.js"; +import type { WebAnalyticsDateRangePickerElement } from "./date-range-picker.element.js"; import "./date-range-picker.element.js"; afterEach(() => { @@ -16,7 +16,7 @@ afterEach(() => { describe("analytics date range picker", () => { it("hydrates a preset end as today and a custom end as its selected inclusive date", async () => { - const preset = document.createElement("vercel-analytics-date-range-picker") as VercelAnalyticsDateRangePickerElement; + const preset = document.createElement("web-analytics-date-range-picker") as WebAnalyticsDateRangePickerElement; preset.range = dateRangeForPreset(30, new Date("2026-07-16T12:00:00Z"), "UTC"); preset.preset = 30; document.body.append(preset); @@ -24,7 +24,7 @@ describe("analytics date range picker", () => { expect(dateInputValues(preset)).toEqual(["2026-06-16", "2026-07-16"]); - const custom = document.createElement("vercel-analytics-date-range-picker") as VercelAnalyticsDateRangePickerElement; + const custom = document.createElement("web-analytics-date-range-picker") as WebAnalyticsDateRangePickerElement; custom.range = normalizeCustomRange("2026-07-09", "2026-07-16", "UTC")!; custom.preset = "custom"; document.body.append(custom); @@ -34,7 +34,7 @@ describe("analytics date range picker", () => { }); }); -async function openPicker(element: VercelAnalyticsDateRangePickerElement): Promise { +async function openPicker(element: WebAnalyticsDateRangePickerElement): Promise { await element.updateComplete; const details = element.shadowRoot?.querySelector("details"); details!.open = true; @@ -42,7 +42,7 @@ async function openPicker(element: VercelAnalyticsDateRangePickerElement): Promi await element.updateComplete; } -function dateInputValues(element: VercelAnalyticsDateRangePickerElement): string[] { +function dateInputValues(element: WebAnalyticsDateRangePickerElement): string[] { return [...element.shadowRoot?.querySelectorAll(".date-inputs uui-input") ?? []] .map((input) => (input as HTMLElement & { value: string }).value); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts index 0964a25..d1fefdf 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range-picker.element.ts @@ -37,8 +37,8 @@ const PRESETS: ReadonlyArray<{ value: Exclude; label: stri const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; -@customElement("vercel-analytics-date-range-picker") -export class VercelAnalyticsDateRangePickerElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-date-range-picker") +export class WebAnalyticsDateRangePickerElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) range: AnalyticsDateRange = dateRangeForPreset(30); @property({ attribute: false }) preset: DatePreset = 30; @@ -308,6 +308,6 @@ export class VercelAnalyticsDateRangePickerElement extends UmbElementMixin(LitEl declare global { interface HTMLElementTagNameMap { - "vercel-analytics-date-range-picker": VercelAnalyticsDateRangePickerElement; + "web-analytics-date-range-picker": WebAnalyticsDateRangePickerElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts index 010352b..cbe9c5b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.test.ts @@ -163,6 +163,12 @@ describe("analytics date ranges", () => { expect(formatAnalyticsTooltipDate("2026-07-01T00:00:00Z", "Month", "en-US")).toBe("July 2026"); }); + it("preserves provider-local trend bucket labels without timezone conversion", () => { + expect(formatAnalyticsDate("2026-07-15", "Day", "en-US", "America/Los_Angeles")).toBe("Jul 15"); + expect(formatAnalyticsDate("2026-07-15T23:00:00", "Hour", "en-US", "Europe/Copenhagen")).toBe("11:00 PM"); + expect(formatAnalyticsTooltipDate("2026-07-15T23:00:00", "Hour", "en-US", "America/New_York")).toContain("11:00 PM"); + }); + it("identifies the current aggregate bucket as incomplete", () => { const now = new Date("2026-07-16T10:30:00Z"); @@ -171,5 +177,17 @@ describe("analytics date ranges", () => { expect(isAnalyticsPeriodInProgress("2026-07-10T00:00:00Z", "Week", now)).toBe(true); expect(isAnalyticsPeriodInProgress("2026-07-01T00:00:00Z", "Month", now)).toBe(true); expect(isAnalyticsPeriodInProgress("2026-07-15T00:00:00Z", "Day", now)).toBe(false); + expect(isAnalyticsPeriodInProgress("2026-07-16T10:00:00", "Hour", now)).toBe(true); + expect(isAnalyticsPeriodInProgress("2026-07-16", "Day", now)).toBe(true); + }); + + it("identifies provider-local Plausible buckets in the reporting timezone", () => { + const now = new Date("2026-07-20T22:30:00Z"); + + expect(isAnalyticsPeriodInProgress("2026-07-21", "Day", now, "Europe/Copenhagen")).toBe(true); + expect(isAnalyticsPeriodInProgress("2026-07-21T00:00:00", "Hour", now, "Europe/Copenhagen")).toBe(true); + expect(isAnalyticsPeriodInProgress("2026-07-20", "Week", now, "Europe/Copenhagen")).toBe(true); + expect(isAnalyticsPeriodInProgress("2026-07-01", "Month", now, "Europe/Copenhagen")).toBe(true); + expect(isAnalyticsPeriodInProgress("2026-07-20", "Day", now, "Europe/Copenhagen")).toBe(false); }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts index 165e7d4..1d7cb9d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -179,7 +179,9 @@ export function formatAnalyticsDate( locale?: string, timeZone = browserTimeZone(), ): string { - const date = new Date(timestamp); + const parsed = analyticsDisplayTimestamp(timestamp, timeZone); + const { date } = parsed; + timeZone = parsed.timeZone; if (interval === "Month") { const month = new Intl.DateTimeFormat(locale, { month: "short", timeZone }).format(date); const year = new Intl.DateTimeFormat("en", { year: "2-digit", timeZone }).format(date); @@ -198,7 +200,9 @@ export function formatAnalyticsTooltipDate( locale?: string, timeZone = browserTimeZone(), ): string { - const date = new Date(timestamp); + const parsed = analyticsDisplayTimestamp(timestamp, timeZone); + const { date } = parsed; + timeZone = parsed.timeZone; if (interval === "Hour") { return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", weekday: "short", hour: "numeric", minute: "2-digit", timeZone, @@ -239,8 +243,20 @@ export function isAnalyticsPeriodInProgress( timestamp: string, interval: AnalyticsInterval, now = new Date(), + timeZone = "UTC", ): boolean { - const start = new Date(timestamp); + if (/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$/.test(timestamp)) { + const currentDate = analyticsDateOnly(now.toISOString(), timeZone); + const bucketDate = timestamp.slice(0, 10); + if (interval === "Month") return bucketDate.slice(0, 7) === currentDate.slice(0, 7); + if (interval === "Week") { + const nextWeek = shiftCalendarDate(bucketDate, 7); + return currentDate >= bucketDate && nextWeek !== undefined && currentDate < nextWeek; + } + if (interval === "Day") return bucketDate === currentDate; + return timestamp.slice(0, 13) === zonedHourKey(now, timeZone); + } + const start = analyticsDisplayTimestamp(timestamp, "UTC").date; if (Number.isNaN(start.valueOf())) return false; const end = new Date(start); if (interval === "Month") end.setUTCMonth(end.getUTCMonth() + 1); @@ -250,6 +266,25 @@ export function isAnalyticsPeriodInProgress( return start <= now && now < end; } +function zonedHourKey(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en", { + year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", + hourCycle: "h23", timeZone, + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; + return `${value("year")}-${value("month")}-${value("day")}T${value("hour")}`; +} + +function analyticsDisplayTimestamp(timestamp: string, timeZone: string): { date: Date; timeZone: string } { + if (/^\d{4}-\d{2}-\d{2}$/.test(timestamp)) { + return { date: new Date(`${timestamp}T00:00:00Z`), timeZone: "UTC" }; + } + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(timestamp)) { + return { date: new Date(`${timestamp}Z`), timeZone: "UTC" }; + } + return { date: new Date(timestamp), timeZone }; +} + function validIso(value: string): string | undefined { const date = new Date(value); return value && !Number.isNaN(date.valueOf()) ? date.toISOString() : undefined; 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..74e7bb4 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, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, culture, hostname: "www.example.com", path: `/${culture}`, diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts new file mode 100644 index 0000000..cb73ac0 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@umbraco-cms/backoffice/element-api", () => ({ + UmbElementMixin: (base: T) => base, +})); +vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); + +import type { WebAnalyticsEventDetailsDialogElement } from "./event-details-dialog.element.js"; +import "./event-details-dialog.element.js"; + +beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); +afterEach(() => document.body.replaceChildren()); + +describe("event details dialog layout", () => { + it("reserves the report height while initial details are loading", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.loading = true; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector(".dialog-content")?.classList.contains("no-properties")).toBe(false); + expect(dialog.shadowRoot?.querySelector(".loading")?.textContent).toContain("Loading event details"); + }); + + it("keeps the report height and explains missing property data", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Read case"; + dialog.propertiesEnabled = true; + dialog.details = { eventName: "Read case", totals: { count: 15, visitors: 12 }, properties: [] }; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector(".dialog-content")?.classList.contains("no-properties")).toBe(false); + expect(dialog.shadowRoot?.querySelector("umb-empty-state")?.getAttribute("headline")).toBe("No property data"); + expect(dialog.shadowRoot?.textContent).toContain("No custom property values were found"); + }); + + it("renders property tabs as the first table heading without repeating the active property", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Read case"; + dialog.propertiesEnabled = true; + dialog.details = { + eventName: "Read case", + totals: { count: 15, visitors: 12 }, + properties: [ + { name: "title", values: [] }, + { name: "locale", values: [] }, + ], + }; + document.body.append(dialog); + await dialog.updateComplete; + + const heading = dialog.shadowRoot?.querySelector("thead .property-heading"); + expect(heading?.querySelector('[role="tablist"]')).not.toBeNull(); + expect(heading?.querySelectorAll('[role="tab"]')).toHaveLength(2); + expect(dialog.shadowRoot?.querySelectorAll("thead th")).toHaveLength(3); + expect(dialog.shadowRoot?.querySelector("thead")?.textContent?.match(/title/g)).toHaveLength(1); + }); + + it("does not render property controls when properties are unavailable", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Read case"; + dialog.filterProperty = "title"; + dialog.filterValue = "A case study"; + dialog.details = { + eventName: "Read case", + totals: { count: 15, visitors: 12 }, + properties: [{ name: "title", values: [{ value: "A case study", count: 15, visitors: 12 }] }], + }; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector('[role="tablist"]')).toBeNull(); + expect(dialog.shadowRoot?.querySelector('uui-input[type="search"]')).toBeNull(); + expect(dialog.shadowRoot?.querySelector(".filter-button")).toBeNull(); + expect(dialog.shadowRoot?.querySelector(".active-filter")).toBeNull(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts index 4fc856d..8b394b3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts @@ -4,9 +4,10 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types.gen.js"; -@customElement("vercel-analytics-event-details-dialog") -export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-event-details-dialog") +export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitElement) { @property() eventName = "Event"; + @property({ type: Boolean }) propertiesEnabled = false; @property({ type: Boolean }) loading = false; @property() unavailable?: string; @property({ attribute: false }) details?: AnalyticsEventDetails; @@ -105,38 +106,37 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li const maximum = Math.max(...values.map((value) => value.count), 1); return html`
- - - - - - - - - ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` - - ` : ""} - ${property.values.length ? html` - - ` : ""} - - ${this.searchLoading ? html` +
+ ${property.values.length ? html` + + + + ` : ""} + ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` + + ` : ""} +
+
+
${property.name} values for ${this.eventName}
${this.#renderPropertyTabs(property)}VisitorsTotal events
- -
- - - -
+ + + + + + + + + ${this.searchLoading ? html` ` : this.searchUnavailable ? html` @@ -165,8 +165,19 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li `; - }) : html``} -
${property.name} values for ${this.eventName}
${this.#renderPropertyTabs(property)}VisitorsTotal events

Looking up matching values…

${this.searchUnavailable}

${this.localize.number(value.count)}

${search ? "Try a different search." : "No values were recorded for this property in the selected period."}

+ }) : html`

${search ? "Try a different search." : "No values were recorded for this property in the selected period."}

`} + +
+
+ `; + } + + #renderNoProperties() { + return html` +
+ +

No custom property values were found for this event in the selected period.

+
`; } @@ -175,12 +186,13 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li const activeProperty = this.#activeProperty(); return html` - +
${this.details ? html` - ${activeProperty ? html` - ${this.#renderProperty(activeProperty)} - ` : html`

This event has totals, but no custom data properties were recorded in the selected period.

`} + ${this.propertiesEnabled ? activeProperty ? html` + ${this.#renderProperty(activeProperty)} + ` : this.#renderNoProperties() + : ""} ${this.loading ? html`
Updating event details…
` : ""} ${this.unavailable ? html`` : ""} ` : this.loading ? html`
Loading event details…
` : this.unavailable ? html`

${this.unavailable}

` : ""} @@ -192,29 +204,29 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li } static styles = [UmbTextStyles, css` - dialog { border: 0; border-radius: var(--uui-border-radius); box-shadow: var(--uui-shadow-depth-5); box-sizing: border-box; margin: auto; max-height: min(52rem, calc(100dvh - 2 * var(--uui-size-layout-1))); max-width: min(52rem, calc(100vw - 2 * var(--uui-size-layout-1))); padding: 0; width: 100%; } + dialog { border: 0; border-radius: var(--uui-border-radius); box-shadow: var(--uui-shadow-depth-5); box-sizing: border-box; margin: auto; max-height: min(52rem, calc(100dvh - 2 * var(--uui-size-layout-1))); max-width: min(50rem, calc(100vw - 2 * var(--uui-size-layout-1))); padding: 0; width: 100%; } dialog::backdrop { background: rgb(0 0 0 / 45%); } uui-dialog-layout { --uui-size-10: var(--uui-size-space-5); --uui-size-14: var(--uui-size-space-6); } - .dialog-content { block-size: min(30rem, 52dvh); display: flex; flex-direction: column; min-block-size: 0; position: relative; } - .property-tabs { display: flex; gap: var(--uui-size-space-1); inline-size: calc(100% + var(--uui-size-space-5)); margin-inline-start: calc(-1 * var(--uui-size-space-5)); overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin; } + .dialog-content { block-size: min(28rem, 52dvh); display: flex; flex-direction: column; min-block-size: 0; position: relative; } + .property-controls { display: grid; flex: 0 0 auto; gap: var(--uui-size-space-3); padding-block-end: var(--uui-size-space-4); } + .property-tabs { display: flex; gap: var(--uui-size-space-1); margin: calc(-1 * var(--uui-size-space-3)) calc(-1 * var(--uui-size-space-5)); overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin; } .property-tabs button { appearance: none; background: transparent; border: 0; border-bottom: 3px solid transparent; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; padding: var(--uui-size-space-3) var(--uui-size-space-4); } .property-tabs button:first-child { padding-inline-start: var(--uui-size-space-5); } .property-tabs button:hover { color: var(--uui-color-text); } .property-tabs button[aria-selected="true"] { border-bottom-color: var(--uui-color-selected); color: var(--uui-color-text); font-weight: 700; } .property-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -3px; } - #event-property-panel { flex: 1; margin-inline: calc(-1 * var(--uui-size-space-5)); min-block-size: 0; overflow: auto; scrollbar-gutter: stable; } + .property-controls uui-input { box-sizing: border-box; width: 100%; } + .property-controls uui-input [slot="prepend"] { align-items: center; display: flex; margin-inline: var(--uui-size-space-3) var(--uui-size-space-2); } + #event-property-panel { display: flex; flex: 1; flex-direction: column; min-block-size: 0; } + .property-table { flex: 1; margin-inline: calc(-1 * var(--uui-size-space-5)); min-block-size: 0; overflow: auto; scrollbar-gutter: stable; } table { --bar-inset: var(--uui-size-space-3); border-collapse: separate; border-spacing: 0; min-inline-size: 34rem; table-layout: fixed; width: 100%; } caption { clip: rect(0 0 0 0); height: 1px; overflow: hidden; position: absolute; width: 1px; } th, td { box-sizing: border-box; padding: var(--uui-size-space-3) var(--uui-size-space-5); text-align: left; } thead { background: var(--uui-color-surface); box-shadow: 0 1px 0 var(--uui-color-border); position: sticky; top: 0; z-index: 3; } thead th { background: var(--uui-color-surface); font-weight: 700; } - .metric-headings th:first-child { padding-block: 0; } - .active-filter-row th { padding-block: var(--uui-size-space-2); } + .property-heading { overflow: hidden; padding-block: var(--uui-size-space-3); } .active-filter { align-items: center; background: var(--uui-color-surface-alt); border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); color: var(--uui-color-text); cursor: pointer; display: inline-flex; gap: var(--uui-size-space-2); max-inline-size: 100%; padding: var(--uui-size-space-2) var(--uui-size-space-3); } .active-filter span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .search-row th { padding-block: var(--uui-size-space-3); } - .search-row uui-input { box-sizing: border-box; width: 100%; } - .search-row uui-input [slot="prepend"] { align-items: center; display: flex; margin-inline: var(--uui-size-space-3) var(--uui-size-space-2); } .metric-headings th { box-shadow: 0 1px 0 var(--uui-color-border); } thead th:not(:first-child), td { text-align: right; width: 8rem; } tbody th { font-weight: 500; min-width: 12rem; position: relative; } @@ -228,6 +240,8 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li .bar { inset-block: var(--uui-size-space-1); inset-inline-start: var(--bar-inset); inline-size: calc(100% + 16rem - 2 * var(--bar-inset)); position: absolute; } .bar::before { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); block-size: 100%; border-radius: var(--uui-border-radius); content: ""; display: block; inline-size: max(var(--bar-minimum), var(--bar-width)); } .loading, .state-message { box-sizing: border-box; flex: 1; padding: var(--uui-size-space-5); } + .no-properties-state { align-items: center; box-sizing: border-box; display: flex; flex: 1; justify-content: center; padding: var(--uui-size-space-5); text-align: center; } + .no-properties-state p { margin-inline: auto; max-inline-size: 34rem; } .loading-overlay { background: color-mix(in srgb, var(--uui-color-surface) 82%, transparent); inset: 0; padding: var(--uui-size-space-5); position: absolute; z-index: 4; } .error-overlay { background: color-mix(in srgb, var(--uui-color-warning) 8%, var(--uui-color-surface)); border: 1px solid color-mix(in srgb, var(--uui-color-warning) 28%, var(--uui-color-border)); border-radius: var(--uui-border-radius); inset-block-start: var(--uui-size-space-3); inset-inline: var(--uui-size-space-3); padding: var(--uui-size-space-4); position: absolute; z-index: 5; } .empty-row td { padding: var(--uui-size-space-5); text-align: left; } @@ -240,4 +254,4 @@ export class VercelAnalyticsEventDetailsDialogElement extends UmbElementMixin(Li `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-event-details-dialog": VercelAnalyticsEventDetailsDialogElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-event-details-dialog": WebAnalyticsEventDetailsDialogElement; } } 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..1d43122 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -6,12 +6,14 @@ import type { AnalyticsEventRow } from "../api/types.gen.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import "./event-table.element.js"; -@customElement("vercel-analytics-event-dialog") -export class VercelAnalyticsEventDialogElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-event-dialog") +export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) { @property({ type: Boolean }) loading = false; @property() unavailable?: string; @property({ attribute: false }) rows: AnalyticsEventRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ type: Boolean }) detailsEnabled = true; + @property({ type: Boolean }) filteringEnabled = false; @state() private _search = ""; protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } @@ -34,7 +36,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 @@ -53,4 +55,4 @@ export class VercelAnalyticsEventDialogElement extends UmbElementMixin(LitElemen `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-event-dialog": VercelAnalyticsEventDialogElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-event-dialog": WebAnalyticsEventDialogElement; } } 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..ddac556 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts @@ -4,14 +4,14 @@ import type { AnalyticsEventRow } from "../api/types.gen.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { visibleEventRows } from "./event-rows.js"; -const EVENTS_SETUP_URL = "https://vercel.com/docs/analytics/custom-events"; - -@customElement("vercel-analytics-event-table") -export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-event-table") +export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { @property({ type: Boolean }) loading = false; @property({ type: Number }) skeletonRows = 10; @property({ attribute: false }) rows: AnalyticsEventRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ type: Boolean }) detailsEnabled = true; + @property({ type: Boolean }) filteringEnabled = false; #select(eventName: string): void { this.dispatchEvent(new CustomEvent("select-event", { @@ -28,8 +28,8 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement return html` ${this.loading ? html`Loading events` : ""} - - + + ${this.loading ? Array.from({ length: this.skeletonRows }, () => html` @@ -41,10 +41,12 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement @@ -67,8 +69,7 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement
No events -

Track custom events to understand which actions visitors take.

- Set up event tracking +

Configure events or goals to understand which actions visitors take.

` : ""} `; @@ -89,6 +90,7 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement .details-action { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; font: inherit; max-width: 100%; overflow: hidden; padding: 0; position: relative; text-align: left; text-overflow: ellipsis; white-space: nowrap; z-index: 1; } .details-action:hover { text-decoration: underline; text-underline-offset: 0.18em; } .details-action:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } + .event-name { position: relative; z-index: 1; } .metric-cell { align-items: center; display: flex; gap: var(--uui-size-space-2); justify-content: flex-end; } .filter-action { align-items: center; appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; display: inline-flex; font: inherit; justify-content: center; opacity: 0; padding: var(--uui-size-space-2); } tbody tr:hover .filter-action, .filter-action:focus-visible, .filter-action[aria-pressed="true"] { opacity: 1; } @@ -112,6 +114,6 @@ export class VercelAnalyticsEventTableElement extends UmbElementMixin(LitElement declare global { interface HTMLElementTagNameMap { - "vercel-analytics-event-table": VercelAnalyticsEventTableElement; + "web-analytics-event-table": WebAnalyticsEventTableElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts index e21c7d2..40e150c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts @@ -6,8 +6,8 @@ import { stateData, type AsyncState } from "./async-state.js"; const FLAGS_SETUP_URL = "https://vercel.com/docs/flags/observability/web-analytics"; -@customElement("vercel-analytics-flag-card") -export class VercelAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-flag-card") +export class WebAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) report: AsyncState = { status: "loading" }; @property({ attribute: false }) selected?: AsyncState; @@ -107,4 +107,4 @@ export class VercelAnalyticsFlagCardElement extends UmbElementMixin(LitElement) `]; } -declare global { interface HTMLElementTagNameMap { "vercel-analytics-flag-card": VercelAnalyticsFlagCardElement; } } +declare global { interface HTMLElementTagNameMap { "web-analytics-flag-card": WebAnalyticsFlagCardElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts index aa20ea1..6f69a06 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts @@ -23,8 +23,8 @@ import { formatAnalyticsDate, formatAnalyticsTooltipDate, isAnalyticsPeriodInPro Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip); -@customElement("vercel-analytics-history-chart") -export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-history-chart") +export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) points: Array<{ timestamp: string; visitors: number; pageViews?: number; count?: number }> = []; @property() metric: "visitors" | "pageViews" | "count" = "visitors"; @property() interval: AnalyticsInterval = "Day"; @@ -46,9 +46,9 @@ export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitEleme this.#chart?.destroy(); const style = getComputedStyle(this); - const color = style.getPropertyValue("--vercel-analytics-chart-color").trim() || "oklch(51.51% .2399 257.85)"; + const color = style.getPropertyValue("--web-analytics-chart-color").trim() || "oklch(51.51% .2399 257.85)"; const lineColor = `color-mix(in srgb, ${color} 72%, transparent)`; - const fillColor = style.getPropertyValue("--vercel-analytics-chart-fill").trim() || "oklch(51.51% .2399 257.85 / 0.12)"; + const fillColor = style.getPropertyValue("--web-analytics-chart-fill").trim() || "oklch(51.51% .2399 257.85 / 0.12)"; const guideColor = style.getPropertyValue("--uui-color-text").trim() || "#1b264f"; const mutedColor = style.getPropertyValue("--uui-color-text-alt").trim() || "#5c5c5c"; const surfaceColor = style.getPropertyValue("--uui-color-surface").trim() || "#ffffff"; @@ -57,10 +57,10 @@ export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitEleme const label = this.#metricLabel(); const latestPoint = this.points[this.points.length - 1]; const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval) + ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) : false; const hoverGuide: Plugin<"line"> = { - id: "vercelAnalyticsHoverGuide", + id: "webAnalyticsHoverGuide", afterDatasetsDraw: (chart) => { const activeElement = chart.tooltip?.getActiveElements()[0]; if (!activeElement) return; @@ -119,8 +119,11 @@ export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitEleme callbacks: { label: (context) => `${label} ${this.localize.number(context.parsed.y ?? 0)}`, title: (items) => { - const point = this.points[items[0]?.dataIndex ?? -1]; - return point ? formatAnalyticsTooltipDate(point.timestamp, this.interval, locale, this.timeZone) : ""; + const index = items[0]?.dataIndex ?? -1; + const point = this.points[index]; + if (!point) return ""; + const title = formatAnalyticsTooltipDate(point.timestamp, this.interval, locale, this.timeZone); + return latestPeriodInProgress && index === this.points.length - 1 ? `${title} · Ongoing` : title; }, }, cornerRadius: 3, @@ -161,7 +164,7 @@ export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitEleme const label = this.#metricLabel(); const latestPoint = this.points[this.points.length - 1]; const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval) + ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) : false; const progressDescription = latestPeriodInProgress ? ". The final period is still in progress" : ""; return html` @@ -185,6 +188,6 @@ export class VercelAnalyticsHistoryChartElement extends UmbElementMixin(LitEleme declare global { interface HTMLElementTagNameMap { - "vercel-analytics-history-chart": VercelAnalyticsHistoryChartElement; + "web-analytics-history-chart": WebAnalyticsHistoryChartElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.test.ts index 065dd2c..3146c5e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.test.ts @@ -3,7 +3,7 @@ import { reportErrorMessage } from "./report-error.js"; describe("report error guidance", () => { it("explains authentication failures without exposing server details", () => { - expect(reportErrorMessage({ status: 502, code: "invalid_credentials" })).toContain("access token"); + expect(reportErrorMessage({ status: 502, code: "invalid_credentials" })).toContain("configured analytics credentials"); }); it("explains plan and reporting-window failures", () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts index 530b6c7..1683dc0 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts @@ -13,29 +13,29 @@ export function reportErrorMessage(error: unknown): string { switch (code) { case "invalid_credentials": - return "Check that the Vercel access token can read Web Analytics for this project."; + return "Check that the configured analytics credentials can read reports for this connection."; case "plan_limit": - return "This report is outside the reporting window or is unavailable on the current Vercel plan."; + return "This report is outside the reporting window or unavailable on the current analytics plan."; case "invalid_query": - return "This query or reporting dimension is not supported by Vercel."; + return "This query or reporting dimension is not supported by the selected analytics provider."; case "upstream_timeout": - return "Vercel Analytics did not respond in time. Try again."; + return "The analytics provider did not respond in time. Try again."; case "report_capacity": return "Analytics is busy. Try again shortly."; case "upstream_transport": case "invalid_upstream_payload": case "upstream_unavailable": - return "Vercel Analytics is temporarily unavailable. Try again."; + return "The analytics provider is temporarily unavailable. Try again."; } switch (status) { case 400: - return "This query or reporting dimension is not supported by Vercel."; + return "This query or reporting dimension is not supported by the selected analytics provider."; case 401: case 403: - return "Check that the Vercel access token can read Web Analytics for this project."; + return "Check that the configured analytics credentials can read reports for this connection."; case 402: - return "This report is outside the reporting window or is unavailable on the current Vercel plan."; + return "This report is outside the reporting window or unavailable on the current analytics plan."; default: return "Analytics could not be loaded. Try again, or check the connection configuration."; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/client.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/client.gen.ts index 5408f51..51ec3f5 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/client.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/client.gen.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; import type { ClientOptions as ClientOptions2 } from './types.gen'; /** @@ -13,6 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig = (override?: Config) => Config & T>; -export const client = createClient(createConfig({ - baseUrl: 'https://localhost:44389' -})); +export const client: Client = createClient(createConfig({ baseUrl: '/' })); diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/client/client.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/client/client.gen.ts index a439d27..fc3f037 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/client/client.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/client/client.gen.ts @@ -3,12 +3,7 @@ import { createSseClient } from '../core/serverSentEvents.gen'; import type { HttpMethod } from '../core/types.gen'; import { getValidRequestBody } from '../core/utils.gen'; -import type { - Client, - Config, - RequestOptions, - ResolvedRequestOptions, -} from './types.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; import { buildUrl, createConfig, @@ -34,27 +29,26 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - const interceptors = createInterceptors< - Request, - Response, - unknown, - ResolvedRequestOptions - >(); + const interceptors = createInterceptors(); - const beforeRequest = async (options: RequestOptions) => { + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { const opts = { ..._config, ...options, fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, + serializedBody: undefined as string | undefined, }; if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); + await setAuthParams(opts); } if (opts.requestValidator) { @@ -62,7 +56,7 @@ export const createClient = (config: Config = {}): Client => { } if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; } // remove Content-Type header if body is empty to avoid sending invalid requests @@ -70,176 +64,191 @@ export const createClient = (config: Config = {}): Client => { opts.headers.delete('Content-Type'); } - const url = buildUrl(opts); + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); - return { opts, url }; + return { opts: resolvedOpts, url }; }; const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; - let request = new Request(url, requestInit); + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } } - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - let response = await _fetch(request); + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } } - } - const result = { - request, - response, - }; + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if ( - response.status === 204 || - response.headers.get('Content-Length') === '0' - ) { - let emptyData: any; + let data: any; switch (parseAs) { case 'arrayBuffer': case 'blob': + case 'formData': case 'text': - emptyData = await response[parseAs](); + data = await response[parseAs](); break; - case 'formData': - emptyData = new FormData(); + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; break; + } case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + return opts.responseStyle === 'data' - ? emptyData + ? data : { - data: emptyData, + data, ...result, }; } - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'json': - case 'text': - data = await response[parseAs](); - break; - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop } - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } + throw jsonError ?? textError; + } catch (error) { + let finalError = error; - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); } } - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } + finalError = finalError || {}; - const error = jsonError ?? textError; - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, response, request, opts)) as string; + if (throwOnError) { + throw finalError; } - } - finalError = finalError || ({} as string); - - if (opts.throwOnError) { - throw finalError; + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; } - - // TODO: we probably want to return error and improve types - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; }; - const makeMethodFn = - (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); - const makeSseFn = - (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); } - return request; - }, - url, - }); - }; + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); return { - buildUrl, + buildUrl: _buildUrl, connect: makeMethodFn('CONNECT'), delete: makeMethodFn('DELETE'), get: makeMethodFn('GET'), diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/client/index.ts b/src/TheBuilder.WebAnalytics/Client/src/api/client/index.ts index cbf8dfe..8c69331 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/client/index.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/client/index.ts @@ -9,6 +9,8 @@ export { } from '../core/bodySerializer.gen'; export { buildClientParams } from '../core/params.gen'; export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; export { createClient } from './client.gen'; export type { Client, @@ -16,7 +18,6 @@ export type { Config, CreateClientConfig, Options, - OptionsLegacyParser, RequestOptions, RequestResult, ResolvedRequestOptions, diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/client/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/client/types.gen.ts index 1a005b5..193646c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/client/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/client/types.gen.ts @@ -5,17 +5,13 @@ import type { ServerSentEventsOptions, ServerSentEventsResult, } from '../core/serverSentEvents.gen'; -import type { - Client as CoreClient, - Config as CoreConfig, -} from '../core/types.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; import type { Middleware } from './utils.gen'; export type ResponseStyle = 'data' | 'fields'; export interface Config - extends Omit, - CoreConfig { + extends Omit, CoreConfig { /** * Base URL for all requests made by this client. */ @@ -42,14 +38,7 @@ export interface Config * * @default 'auto' */ - parseAs?: - | 'arrayBuffer' - | 'auto' - | 'blob' - | 'formData' - | 'json' - | 'stream' - | 'text'; + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; /** * Should we return only data or multiple fields (data, error, response, etc.)? * @@ -69,12 +58,15 @@ export interface RequestOptions< TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, -> extends Config<{ +> + extends + Config<{ responseStyle: TResponseStyle; throwOnError: ThrowOnError; }>, Pick< ServerSentEventsOptions, + | 'onRequest' | 'onSseError' | 'onSseEvent' | 'sseDefaultRetryDelay' @@ -101,6 +93,7 @@ export interface ResolvedRequestOptions< ThrowOnError extends boolean = boolean, Url extends string = string, > extends RequestOptions { + headers: Headers; serializedBody?: string; } @@ -116,36 +109,28 @@ export type RequestResult< ? TData[keyof TData] : TData : { - data: TData extends Record - ? TData[keyof TData] - : TData; + data: TData extends Record ? TData[keyof TData] : TData; request: Request; response: Response; } > : Promise< TResponseStyle extends 'data' - ? - | (TData extends Record - ? TData[keyof TData] - : TData) - | undefined + ? (TData extends Record ? TData[keyof TData] : TData) | undefined : ( | { - data: TData extends Record - ? TData[keyof TData] - : TData; + data: TData extends Record ? TData[keyof TData] : TData; error: undefined; } | { data: undefined; - error: TError extends Record - ? TError[keyof TError] - : TError; + error: TError extends Record ? TError[keyof TError] : TError; } ) & { - request: Request; - response: Response; + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; } >; @@ -166,12 +151,13 @@ type MethodFn = < type SseFn = < TData = unknown, - TError = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'>, -) => Promise>; + options: Omit, 'method'>, +) => Promise>; type RequestFn = < TData = unknown, @@ -180,10 +166,7 @@ type RequestFn = < TResponseStyle extends ResponseStyle = 'fields', >( options: Omit, 'method'> & - Pick< - Required>, - 'method' - >, + Pick>, 'method'>, ) => RequestResult; type BuildUrlFn = < @@ -194,16 +177,10 @@ type BuildUrlFn = < url: string; }, >( - options: Pick & Options, + options: TData & Options, ) => string; -export type Client = CoreClient< - RequestFn, - Config, - MethodFn, - BuildUrlFn, - SseFn -> & { +export type Client = CoreClient & { interceptors: Middleware; }; @@ -238,31 +215,4 @@ export type Options< RequestOptions, 'body' | 'path' | 'query' | 'url' > & - Omit; - -export type OptionsLegacyParser< - TData = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = TData extends { body?: any } - ? TData extends { headers?: any } - ? OmitKeys< - RequestOptions, - 'body' | 'headers' | 'url' - > & - TData - : OmitKeys< - RequestOptions, - 'body' | 'url' - > & - TData & - Pick, 'headers'> - : TData extends { headers?: any } - ? OmitKeys< - RequestOptions, - 'headers' | 'url' - > & - TData & - Pick, 'body'> - : OmitKeys, 'url'> & - TData; + ([TData] extends [never] ? unknown : Omit); diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/client/utils.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/client/utils.gen.ts index b4bcc4d..d4a7284 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/client/utils.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/client/utils.gen.ts @@ -12,11 +12,10 @@ import { getUrl } from '../core/utils.gen'; import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; export const createQuerySerializer = ({ - allowReserved, - array, - object, -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { const search: string[] = []; if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { @@ -26,29 +25,31 @@ export const createQuerySerializer = ({ continue; } + const options = parameters[name] || args; + if (Array.isArray(value)) { const serializedArray = serializeArrayParam({ - allowReserved, + allowReserved: options.allowReserved, explode: true, name, style: 'form', value, - ...array, + ...options.array, }); if (serializedArray) search.push(serializedArray); } else if (typeof value === 'object') { const serializedObject = serializeObjectParam({ - allowReserved, + allowReserved: options.allowReserved, explode: true, name, style: 'deepObject', value: value as Record, - ...object, + ...options.object, }); if (serializedObject) search.push(serializedObject); } else { const serializedPrimitive = serializePrimitiveParam({ - allowReserved, + allowReserved: options.allowReserved, name, value: value as string, }); @@ -64,9 +65,7 @@ export const createQuerySerializer = ({ /** * Infers parseAs value from provided Content-Type header. */ -export const getParseAs = ( - contentType: string | null, -): Exclude => { +export const getParseAs = (contentType: string | null): Exclude => { if (!contentType) { // If no Content-Type header is provided, the best we can do is return the raw response body, // which is effectively the same as the 'stream' option. @@ -79,10 +78,7 @@ export const getParseAs = ( return; } - if ( - cleanContent.startsWith('application/json') || - cleanContent.endsWith('+json') - ) { + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { return 'json'; } @@ -91,9 +87,7 @@ export const getParseAs = ( } if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => - cleanContent.startsWith(type), - ) + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) ) { return 'blob'; } @@ -124,14 +118,12 @@ const checkForExistence = ( return false; }; -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { +export async function setAuthParams( + options: Pick & { headers: Headers; - }) => { - for (const auth of security) { + }, +): Promise { + for (const auth of options.security ?? []) { if (checkForExistence(options, auth.name)) { continue; } @@ -160,7 +152,7 @@ export const setAuthParams = async ({ break; } } -}; +} export const buildUrl: Client['buildUrl'] = (options) => getUrl({ @@ -200,10 +192,7 @@ export const mergeHeaders = ( continue; } - const iterator = - header instanceof Headers - ? headersEntries(header) - : Object.entries(header); + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); for (const [key, value] of iterator) { if (value === null) { @@ -213,7 +202,7 @@ export const mergeHeaders = ( mergedHeaders.append(key, v as string); } } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e. their + // assume object headers are meant to be JSON stringified, i.e., their // content value in OpenAPI specification is 'application/json' mergedHeaders.set( key, @@ -227,15 +216,14 @@ export const mergeHeaders = ( type ErrInterceptor = ( error: Err, - response: Res, - request: Req, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, options: Options, ) => Err | Promise; -type ReqInterceptor = ( - request: Req, - options: Options, -) => Req | Promise; +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; type ResInterceptor = ( response: Res, @@ -269,10 +257,7 @@ class Interceptors { return this.fns.indexOf(id); } - update( - id: number | Interceptor, - fn: Interceptor, - ): number | Interceptor | false { + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { const index = this.getInterceptorIndex(id); if (this.fns[index]) { this.fns[index] = fn; diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/auth.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/auth.gen.ts index f8a7326..c663664 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/auth.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/auth.gen.ts @@ -9,6 +9,13 @@ export interface Auth { * @default 'header' */ in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; /** * Header or query parameter name. * @@ -23,8 +30,7 @@ export const getAuthToken = async ( auth: Auth, callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, ): Promise => { - const token = - typeof callback === 'function' ? await callback(auth) : callback; + const token = typeof callback === 'function' ? await callback(auth) : callback; if (!token) { return; diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/bodySerializer.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/bodySerializer.gen.ts index 49cd892..67daca6 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/bodySerializer.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/bodySerializer.gen.ts @@ -1,26 +1,26 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { - ArrayStyle, - ObjectStyle, - SerializerOptions, -} from './pathSerializer.gen'; +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; export type QuerySerializer = (query: Record) => string; -export type BodySerializer = (body: any) => any; +export type BodySerializer = (body: unknown) => unknown; -export interface QuerySerializerOptions { +type QuerySerializerOptionsObject = { allowReserved?: boolean; - array?: SerializerOptions; - object?: SerializerOptions; -} + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; -const serializeFormDataPair = ( - data: FormData, - key: string, - value: unknown, -): void => { +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { if (typeof value === 'string' || value instanceof Blob) { data.append(key, value); } else if (value instanceof Date) { @@ -30,11 +30,7 @@ const serializeFormDataPair = ( } }; -const serializeUrlSearchParamsPair = ( - data: URLSearchParams, - key: string, - value: unknown, -): void => { +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { if (typeof value === 'string') { data.append(key, value); } else { @@ -43,12 +39,10 @@ const serializeUrlSearchParamsPair = ( }; export const formDataBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): FormData => { + bodySerializer: (body: unknown): FormData => { const data = new FormData(); - Object.entries(body).forEach(([key, value]) => { + Object.entries(body as Record).forEach(([key, value]) => { if (value === undefined || value === null) { return; } @@ -64,19 +58,15 @@ export const formDataBodySerializer = { }; export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => - typeof value === 'bigint' ? value.toString() : value, - ), + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), }; export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): string => { + bodySerializer: (body: unknown): string => { const data = new URLSearchParams(); - Object.entries(body).forEach(([key, value]) => { + Object.entries(body as Record).forEach(([key, value]) => { if (value === undefined || value === null) { return; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/params.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/params.gen.ts index 71c88e8..5e8908f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/params.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/params.gen.ts @@ -22,6 +22,17 @@ export type Field = */ key?: string; map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; }; export interface Fields { @@ -41,13 +52,17 @@ const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< string, - { - in: Slot; - map?: string; - } + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } >; -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { if (!map) { map = new Map(); } @@ -60,42 +75,52 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { map: config.map, }); } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); } else if (config.args) { buildKeyMap(config.args, map); } } return map; -}; +} interface Params { - body: unknown; + body?: unknown; headers: Record; path: Record; query: Record; } -const stripEmptySlots = (params: Params) => { +function stripEmptySlots(params: Params): void { for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === 'object' && !Object.keys(value).length) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { delete params[slot as Slot]; } } -}; +} -export const buildClientParams = ( - args: ReadonlyArray, - fields: FieldsConfig, -) => { +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), }; const map = buildKeyMap(fields); + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + let config: FieldsConfig[number] | undefined; for (const [index, arg] of args.entries()) { @@ -111,7 +136,9 @@ export const buildClientParams = ( if (config.key) { const field = map.get(config.key)!; const name = field.map || config.key; - (params[field.in] as Record)[name] = arg; + if (field.in) { + writeSlot(field.in, name, arg); + } } else { params.body = arg; } @@ -120,24 +147,22 @@ export const buildClientParams = ( const field = map.get(key); if (field) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } } else { - const extra = extraPrefixes.find(([prefix]) => - key.startsWith(prefix), - ); + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); if (extra) { const [prefix, slot] = extra; - (params[slot] as Record)[ - key.slice(prefix.length) - ] = value; - } else { - for (const [slot, allowed] of Object.entries( - config.allowExtra ?? {}, - )) { + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { if (allowed) { - (params[slot as Slot] as Record)[key] = value; + writeSlot(slot as Slot, key, value); break; } } @@ -150,4 +175,4 @@ export const buildClientParams = ( stripEmptySlots(params); return params; -}; +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/pathSerializer.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/pathSerializer.gen.ts index 8d99931..fab1ed4 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/pathSerializer.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/pathSerializer.gen.ts @@ -1,8 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -interface SerializeOptions - extends SerializePrimitiveOptions, - SerializerOptions {} +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} interface SerializePrimitiveOptions { allowReserved?: boolean; @@ -27,7 +25,7 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions { value: string; } -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { switch (style) { case 'label': return '.'; @@ -40,7 +38,7 @@ export const separatorArrayExplode = (style: ArraySeparatorStyle) => { } }; -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { switch (style) { case 'form': return ','; @@ -53,7 +51,7 @@ export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { } }; -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { switch (style) { case 'label': return '.'; @@ -74,7 +72,7 @@ export const serializeArrayParam = ({ value, }: SerializeOptions & { value: unknown[]; -}) => { +}): string => { if (!explode) { const joinedValues = ( allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) @@ -105,16 +103,14 @@ export const serializeArrayParam = ({ }); }) .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; }; export const serializePrimitiveParam = ({ allowReserved, name, value, -}: SerializePrimitiveParam) => { +}: SerializePrimitiveParam): string => { if (value === undefined || value === null) { return ''; } @@ -138,7 +134,7 @@ export const serializeObjectParam = ({ }: SerializeOptions & { value: Record | Date; valueOnly?: boolean; -}) => { +}): string => { if (value instanceof Date) { return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; } @@ -146,11 +142,7 @@ export const serializeObjectParam = ({ if (style !== 'deepObject' && !explode) { let values: string[] = []; Object.entries(value).forEach(([key, v]) => { - values = [ - ...values, - key, - allowReserved ? (v as string) : encodeURIComponent(v as string), - ]; + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; }); const joinedValues = values.join(','); switch (style) { @@ -175,7 +167,5 @@ export const serializeObjectParam = ({ }), ) .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; }; diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/queryKeySerializer.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/queryKeySerializer.gen.ts index d3bb683..773b065 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/queryKeySerializer.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/queryKeySerializer.gen.ts @@ -14,12 +14,8 @@ export type JsonValue = /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { return undefined; } if (typeof value === 'bigint') { @@ -61,9 +57,7 @@ const isPlainObject = (value: unknown): value is Record => { * Turns URLSearchParams into a sorted JSON object for deterministic keys. */ const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => - a.localeCompare(b), - ); + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); const result: Record = {}; for (const [key, value] of entries) { @@ -86,26 +80,16 @@ const serializeSearchParams = (params: URLSearchParams): JsonValue => { /** * Normalizes any accepted value into a JSON-friendly shape for query keys. */ -export const serializeQueryKeyValue = ( - value: unknown, -): JsonValue | undefined => { +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { if (value === null) { return null; } - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value; } - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { return undefined; } @@ -121,10 +105,7 @@ export const serializeQueryKeyValue = ( return stringifyToJsonValue(value); } - if ( - typeof URLSearchParams !== 'undefined' && - value instanceof URLSearchParams - ) { + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { return serializeSearchParams(value); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/serverSentEvents.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/serverSentEvents.gen.ts index f8fd78e..ddf3c4d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/serverSentEvents.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/serverSentEvents.gen.ts @@ -2,10 +2,7 @@ import type { Config } from './types.gen'; -export type ServerSentEventsOptions = Omit< - RequestInit, - 'method' -> & +export type ServerSentEventsOptions = Omit & Pick & { /** * Fetch API implementation. You can use this option to provide a custom @@ -74,11 +71,7 @@ export interface StreamEvent { retry?: number; } -export type ServerSentEventsResult< - TData = unknown, - TReturn = void, - TNext = unknown, -> = { +export type ServerSentEventsResult = { stream: AsyncGenerator< TData extends Record ? TData[keyof TData] : TData, TReturn, @@ -86,7 +79,7 @@ export type ServerSentEventsResult< >; }; -export const createSseClient = ({ +export function createSseClient({ onRequest, onSseError, onSseEvent, @@ -98,12 +91,10 @@ export const createSseClient = ({ sseSleepFn, url, ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { +}: ServerSentEventsOptions): ServerSentEventsResult { let lastEventId: string | undefined; - const sleep = - sseSleepFn ?? - ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const createStream = async function* () { let retryDelay: number = sseDefaultRetryDelay ?? 3000; @@ -141,16 +132,11 @@ export const createSseClient = ({ const _fetch = options.fetch ?? globalThis.fetch; const response = await _fetch(request); - if (!response.ok) - throw new Error( - `SSE failed: ${response.status} ${response.statusText}`, - ); + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); if (!response.body) throw new Error('No body in SSE response'); - const reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ''; @@ -169,6 +155,7 @@ export const createSseClient = ({ const { done, value } = await reader.read(); if (done) break; buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; @@ -186,10 +173,7 @@ export const createSseClient = ({ } else if (line.startsWith('id:')) { lastEventId = line.replace(/^id:\s*/, ''); } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt( - line.replace(/^retry:\s*/, ''), - 10, - ); + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); if (!Number.isNaN(parsed)) { retryDelay = parsed; } @@ -241,18 +225,12 @@ export const createSseClient = ({ // connection failed or aborted; retry after delay onSseError?.(error); - if ( - sseMaxRetryAttempts !== undefined && - attempt >= sseMaxRetryAttempts - ) { + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { break; // stop after firing error } // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min( - retryDelay * 2 ** (attempt - 1), - sseMaxRetryDelay ?? 30000, - ); + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); await sleep(backoff); } } @@ -261,4 +239,4 @@ export const createSseClient = ({ const stream = createStream(); return { stream }; -}; +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/types.gen.ts index 643c070..c657c85 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/types.gen.ts @@ -1,11 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Auth, AuthToken } from './auth.gen'; -import type { - BodySerializer, - QuerySerializer, - QuerySerializerOptions, -} from './bodySerializer.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; export type HttpMethod = | 'connect' @@ -34,9 +30,7 @@ export type Client< setConfig: (config: Config) => Config; } & { [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] - ? { sse?: never } - : { sse: { [K in HttpMethod]: SseFn } }); +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** @@ -59,13 +53,7 @@ export interface Config { | RequestInit['headers'] | Record< string, - | string - | number - | boolean - | (string | number | boolean)[] - | null - | undefined - | unknown + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown >; /** * The request method. @@ -92,7 +80,7 @@ export interface Config { requestValidator?: (data: unknown) => Promise; /** * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. + * for post-processing data, e.g., converting ISO strings into Date objects. */ responseTransformer?: (data: unknown) => Promise; /** @@ -103,6 +91,12 @@ export interface Config { responseValidator?: (data: unknown) => Promise; } +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + type IsExactlyNeverOrNeverUndefined = [T] extends [never] ? true : [T] extends [never | undefined] @@ -112,7 +106,5 @@ type IsExactlyNeverOrNeverUndefined = [T] extends [never] : false; export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true - ? never - : K]: T[K]; + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; }; diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/core/utils.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/core/utils.gen.ts index 0b5389d..af56e07 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/core/utils.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/core/utils.gen.ts @@ -13,9 +13,9 @@ export interface PathSerializer { url: string; } -export const PATH_PARAM_RE = /\{[^{}]+\}/g; +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { let url = _url; const matches = _url.match(PATH_PARAM_RE); if (matches) { @@ -44,10 +44,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { } if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); continue; } @@ -97,7 +94,7 @@ export const getUrl = ({ query?: Record; querySerializer: QuerySerializer; url: string; -}) => { +}): string => { const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; let url = (baseUrl ?? '') + pathUrl; if (path) { @@ -117,7 +114,7 @@ export function getValidRequestBody(options: { body?: unknown; bodySerializer?: BodySerializer | null; serializedBody?: unknown; -}) { +}): unknown { const hasBody = options.body !== undefined; const isSerializedBody = hasBody && options.bodySerializer; @@ -129,7 +126,7 @@ export function getValidRequestBody(options: { return hasSerializedBody ? options.serializedBody : null; } - // not all clients implement a serializedBody property (i.e. client-axios) + // not all clients implement a serializedBody property (i.e., client-axios) return options.body !== '' ? options.body : null; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/index.ts b/src/TheBuilder.WebAnalytics/Client/src/api/index.ts index c352c10..80338db 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/index.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export type * from './types.gen'; -export * from './sdk.gen'; +export { type Options, WebAnalyticsService } from './sdk.gen'; +export type { AnalyticsBreakdown, AnalyticsBreakdownRow, AnalyticsCapabilities, AnalyticsConnectionSettingsResponse, AnalyticsConnectionsResponse, AnalyticsConnectionSummary, AnalyticsConnectionTestResult, AnalyticsCredentialDescriptor, AnalyticsDimension, AnalyticsDocumentRoute, AnalyticsEventDetails, AnalyticsEventProperty, AnalyticsEventPropertyDescriptor, AnalyticsEventPropertyValue, AnalyticsEventRow, AnalyticsEventsReport, AnalyticsEventTotals, AnalyticsFlagRow, AnalyticsFlagsReport, AnalyticsIdentifierFieldDescriptor, AnalyticsInterval, AnalyticsOptionalFieldDescriptor, AnalyticsPoint, AnalyticsProvider, AnalyticsProviderDescriptor, AnalyticsProviderTokenStatus, AnalyticsSettingsResponse, AnalyticsSummary, AnalyticsTotals, AnalyticsTrafficMetric, BreakdownData, BreakdownErrors, BreakdownResponse, BreakdownResponses, ClientOptions, ConnectionsData, ConnectionsErrors, ConnectionsResponse, ConnectionsResponses, DocumentRoutesData, DocumentRoutesErrors, DocumentRoutesResponse, DocumentRoutesResponses, EventDetailsData, EventDetailsErrors, EventDetailsResponse, EventDetailsResponses, EventPropertyValuesData, EventPropertyValuesErrors, EventPropertyValuesResponse, EventPropertyValuesResponses, EventsData, EventsErrors, EventsResponse, EventsResponses, FlagsData, FlagsErrors, FlagsResponse, FlagsResponses, MockAnalyticsScenario, SaveSettingsData, SaveSettingsErrors, SaveSettingsResponse, SaveSettingsResponses, SettingsData, SettingsErrors, SettingsResponse, SettingsResponses, SummaryData, SummaryErrors, SummaryResponse, SummaryResponses, TestConnectionData, TestConnectionErrors, TestConnectionResponse, TestConnectionResponses, UpdateAnalyticsConnectionRequest, UpdateAnalyticsSettingsRequest } from './types.gen'; diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/sdk.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/sdk.gen.ts index c63e797..2c36325 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/sdk.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/sdk.gen.ts @@ -1,10 +1,10 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Client, Options as Options2, TDataShape } from './client'; +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; import type { BreakdownData, BreakdownErrors, BreakdownResponses, ConnectionsData, ConnectionsErrors, ConnectionsResponses, DocumentRoutesData, DocumentRoutesErrors, DocumentRoutesResponses, EventDetailsData, EventDetailsErrors, EventDetailsResponses, EventPropertyValuesData, EventPropertyValuesErrors, EventPropertyValuesResponses, EventsData, EventsErrors, EventsResponses, FlagsData, FlagsErrors, FlagsResponses, SaveSettingsData, SaveSettingsErrors, SaveSettingsResponses, SettingsData, SettingsErrors, SettingsResponses, SummaryData, SummaryErrors, SummaryResponses, TestConnectionData, TestConnectionErrors, TestConnectionResponses } from './types.gen'; -export type Options = Options2 & { +export type Options = Options2 & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a @@ -15,136 +15,86 @@ export type Options; + meta?: keyof ClientMeta extends never ? Record : ClientMeta; }; export class WebAnalyticsService { - public static connections(options?: Options) { + public static connections(options?: Options): RequestResult { return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/connections', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/connections', ...options }); } - public static documentRoutes(options: Options) { + public static documentRoutes(options: Options): RequestResult { return (options.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/documents/{documentId}/routes', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/documents/{documentId}/routes', ...options }); } - public static breakdown(options: Options) { + public static breakdown(options: Options): RequestResult { return (options.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/breakdown/{dimension}', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/breakdown/{dimension}', ...options }); } - public static events(options?: Options) { + public static events(options?: Options): RequestResult { return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/events', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/events', ...options }); } - public static flags(options?: Options) { - return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/flags', + public static eventDetails(options?: Options): RequestResult { + return (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/events/details', ...options }); } - public static eventDetails(options?: Options) { - return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/events/details', + public static eventPropertyValues(options?: Options): RequestResult { + return (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/events/property-values', ...options }); } - public static eventPropertyValues(options?: Options) { - return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/events/property-values', + public static flags(options?: Options): RequestResult { + return (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/flags', ...options }); } - public static summary(options?: Options) { + public static summary(options?: Options): RequestResult { return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/reports/summary', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/reports/summary', ...options }); } - public static settings(options?: Options) { + public static settings(options?: Options): RequestResult { return (options?.client ?? client).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/settings', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/settings', ...options }); } - - public static saveSettings(options?: Options) { + + public static saveSettings(options?: Options): RequestResult { return (options?.client ?? client).put({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/settings', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/settings', ...options, headers: { 'Content-Type': 'application/json', @@ -152,16 +102,11 @@ export class WebAnalyticsService { } }); } - - public static testConnection(options: Options) { + + public static testConnection(options: Options): RequestResult { return (options.client ?? client).post({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/umbraco/management/api/v1/vercel-analytics/settings/connections/{key}/test', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/umbraco/management/api/v1/web-analytics/settings/connections/{key}/test', ...options }); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts index b488570..8596cad 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'https://localhost:44389' | (string & {}); + baseUrl: `${string}://${string}` | (string & {}); }; export type AnalyticsBreakdown = { @@ -15,11 +15,24 @@ export type AnalyticsBreakdownRow = { visitors: number; }; +export type AnalyticsCapabilities = { + dimensions: Array; + events: boolean; + eventDetails: boolean; + eventProperties: boolean; + globalEventFiltering: boolean; + flags: boolean; + breakdownOrdering: boolean; +}; + export type AnalyticsConnectionSettingsResponse = { key: string; displayName: string; + provider: AnalyticsProvider; projectId: string; team?: string | null; + siteId: string; + eventPropertyNames: Array; documentRootKeys: Array; enableAllDocumentTypes: boolean; enabledDocumentTypeKeys: Array; @@ -31,6 +44,8 @@ export type AnalyticsConnectionSettingsResponse = { export type AnalyticsConnectionSummary = { key: string; displayName: string; + provider: AnalyticsProvider; + capabilities: AnalyticsCapabilities; isDefault: boolean; isConfigured: boolean; baseUrl?: string | null; @@ -48,10 +63,18 @@ export type AnalyticsConnectionsResponse = { connections: Array; }; -export type AnalyticsDimension = 'RequestPath' | 'Route' | 'ReferrerHostname' | 'Country' | 'DeviceType' | 'BrowserName' | 'OsName' | 'UtmSource' | 'UtmMedium' | 'UtmCampaign' | 'UtmTerm' | 'UtmContent' | 'EventName'; +export type AnalyticsCredentialDescriptor = { + label: string; + description: string; + documentationUrl: string; +}; + +export type AnalyticsDimension = 'RequestPath' | 'Route' | 'ReferrerHostname' | 'Referrer' | 'Country' | 'DeviceType' | 'BrowserName' | 'OsName' | 'UtmSource' | 'UtmMedium' | 'UtmCampaign' | 'UtmTerm' | 'UtmContent' | 'EventName'; export type AnalyticsDocumentRoute = { connection: string; + provider: AnalyticsProvider; + capabilities: AnalyticsCapabilities; culture: string; hostname: string; path: string; @@ -71,6 +94,13 @@ export type AnalyticsEventProperty = { values: Array; }; +export type AnalyticsEventPropertyDescriptor = { + label: string; + description: string; + maximumNames: number; + maximumNameLength: number; +}; + export type AnalyticsEventPropertyValue = { value: string; count: number; @@ -103,9 +133,20 @@ export type AnalyticsFlagsReport = { rows: Array; }; +export type AnalyticsIdentifierFieldDescriptor = { + key: string; + label: string; + description: string; + requiredMessage: string; +}; + export type AnalyticsInterval = 'Hour' | 'Day' | 'Week' | 'Month'; -export type MockAnalyticsScenario = 'Complete' | 'Utm' | 'Flags' | 'Events'; +export type AnalyticsOptionalFieldDescriptor = { + key: string; + label: string; + description: string; +}; export type AnalyticsPoint = { timestamp: string; @@ -113,18 +154,27 @@ export type AnalyticsPoint = { visitors: number; }; -export type AnalyticsProblemDetails = { - type?: string | null; - title?: string | null; - status?: number | null; - detail?: string | null; - instance?: string | null; - code: string; +export type AnalyticsProvider = 'Vercel' | 'Plausible'; + +export type AnalyticsProviderDescriptor = { + provider: AnalyticsProvider; + description: string; + logoSlug: string; + identifier: AnalyticsIdentifierFieldDescriptor; + team?: AnalyticsOptionalFieldDescriptor | null; + credential: AnalyticsCredentialDescriptor; + eventProperties?: AnalyticsEventPropertyDescriptor | null; +}; + +export type AnalyticsProviderTokenStatus = { + provider: AnalyticsProvider; + hasAccessToken: boolean; }; export type AnalyticsSettingsResponse = { enabled: boolean; - hasAccessToken: boolean; + providers: Array; + providerTokens: Array; canCreateMockConnections: boolean; defaultRangeDays: number; cacheDuration: string; @@ -142,19 +192,18 @@ export type AnalyticsTotals = { visitors: number; }; -export type EventMessageTypeModel = 'Default' | 'Info' | 'Error' | 'Success' | 'Warning'; +export type AnalyticsTrafficMetric = 'Visitors' | 'PageViews'; -export type NotificationHeaderModel = { - message: string; - category: string; - type: EventMessageTypeModel; -}; +export type MockAnalyticsScenario = 'Complete' | 'Utm' | 'Flags' | 'Events'; export type UpdateAnalyticsConnectionRequest = { key: string; displayName: string; + provider: AnalyticsProvider; projectId: string; team?: string | null; + siteId: string; + eventPropertyNames: Array; mockScenario?: MockAnalyticsScenario | null; documentRootKeys: Array; enableAllDocumentTypes: boolean; @@ -172,7 +221,7 @@ export type ConnectionsData = { body?: never; path?: never; query?: never; - url: '/umbraco/management/api/v1/vercel-analytics/connections'; + url: '/umbraco/management/api/v1/web-analytics/connections'; }; export type ConnectionsErrors = { @@ -203,7 +252,7 @@ export type DocumentRoutesData = { query?: { culture?: string; }; - url: '/umbraco/management/api/v1/vercel-analytics/documents/{documentId}/routes'; + url: '/umbraco/management/api/v1/web-analytics/documents/{documentId}/routes'; }; export type DocumentRoutesErrors = { @@ -234,12 +283,13 @@ export type BreakdownData = { interval?: AnalyticsInterval; limit?: number; search?: string; + orderBy?: AnalyticsTrafficMetric; documentId?: string; culture?: string; path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/breakdown/{dimension}'; + url: '/umbraco/management/api/v1/web-analytics/reports/breakdown/{dimension}'; }; export type BreakdownErrors = { @@ -247,12 +297,6 @@ export type BreakdownErrors = { * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; export type BreakdownResponses = { @@ -279,7 +323,7 @@ export type EventsData = { path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/events'; + url: '/umbraco/management/api/v1/web-analytics/reports/events'; }; export type EventsErrors = { @@ -287,12 +331,6 @@ export type EventsErrors = { * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; export type EventsResponses = { @@ -304,7 +342,7 @@ export type EventsResponses = { export type EventsResponse = EventsResponses[keyof EventsResponses]; -export type FlagsData = { +export type EventDetailsData = { body?: never; path?: never; query?: { @@ -312,41 +350,34 @@ export type FlagsData = { from?: string; to?: string; interval?: AnalyticsInterval; - flagKey?: string; - limit?: number; + eventName?: string; + eventProperty?: string; + eventValue?: string; documentId?: string; culture?: string; path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/flags'; + url: '/umbraco/management/api/v1/web-analytics/reports/events/details'; }; -export type FlagsErrors = { +export type EventDetailsErrors = { /** * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; -export type FlagsError = FlagsErrors[keyof FlagsErrors]; - -export type FlagsResponses = { +export type EventDetailsResponses = { /** * OK */ - 200: AnalyticsFlagsReport; + 200: AnalyticsEventDetails; }; -export type FlagsResponse = FlagsResponses[keyof FlagsResponses]; +export type EventDetailsResponse = EventDetailsResponses[keyof EventDetailsResponses]; -export type EventDetailsData = { +export type EventPropertyValuesData = { body?: never; path?: never; query?: { @@ -355,6 +386,9 @@ export type EventDetailsData = { to?: string; interval?: AnalyticsInterval; eventName?: string; + propertyName?: string; + limit?: number; + search?: string; eventProperty?: string; eventValue?: string; documentId?: string; @@ -362,32 +396,26 @@ export type EventDetailsData = { path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/events/details'; + url: '/umbraco/management/api/v1/web-analytics/reports/events/property-values'; }; -export type EventDetailsErrors = { +export type EventPropertyValuesErrors = { /** * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; -export type EventDetailsResponses = { +export type EventPropertyValuesResponses = { /** * OK */ - 200: AnalyticsEventDetails; + 200: AnalyticsEventProperty; }; -export type EventDetailsResponse = EventDetailsResponses[keyof EventDetailsResponses]; +export type EventPropertyValuesResponse = EventPropertyValuesResponses[keyof EventPropertyValuesResponses]; -export type EventPropertyValuesData = { +export type FlagsData = { body?: never; path?: never; query?: { @@ -395,41 +423,31 @@ export type EventPropertyValuesData = { from?: string; to?: string; interval?: AnalyticsInterval; - eventName?: string; - propertyName?: string; + flagKey?: string; limit?: number; - search?: string; - eventProperty?: string; - eventValue?: string; documentId?: string; culture?: string; path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/events/property-values'; + url: '/umbraco/management/api/v1/web-analytics/reports/flags'; }; -export type EventPropertyValuesErrors = { +export type FlagsErrors = { /** * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; -export type EventPropertyValuesResponses = { +export type FlagsResponses = { /** * OK */ - 200: AnalyticsEventProperty; + 200: AnalyticsFlagsReport; }; -export type EventPropertyValuesResponse = EventPropertyValuesResponses[keyof EventPropertyValuesResponses]; +export type FlagsResponse = FlagsResponses[keyof FlagsResponses]; export type SummaryData = { body?: never; @@ -444,7 +462,7 @@ export type SummaryData = { path?: string; filter?: Array; }; - url: '/umbraco/management/api/v1/vercel-analytics/reports/summary'; + url: '/umbraco/management/api/v1/web-analytics/reports/summary'; }; export type SummaryErrors = { @@ -452,12 +470,6 @@ export type SummaryErrors = { * The resource is protected and requires an authentication token */ 401: unknown; - 400: AnalyticsProblemDetails; - 402: AnalyticsProblemDetails; - 404: AnalyticsProblemDetails; - 502: AnalyticsProblemDetails; - 503: AnalyticsProblemDetails; - 504: AnalyticsProblemDetails; }; export type SummaryResponses = { @@ -473,7 +485,7 @@ export type SettingsData = { body?: never; path?: never; query?: never; - url: '/umbraco/management/api/v1/vercel-analytics/settings'; + url: '/umbraco/management/api/v1/web-analytics/settings'; }; export type SettingsErrors = { @@ -496,7 +508,7 @@ export type SaveSettingsData = { body?: UpdateAnalyticsSettingsRequest; path?: never; query?: never; - url: '/umbraco/management/api/v1/vercel-analytics/settings'; + url: '/umbraco/management/api/v1/web-analytics/settings'; }; export type SaveSettingsErrors = { @@ -525,7 +537,7 @@ export type TestConnectionData = { key: string; }; query?: never; - url: '/umbraco/management/api/v1/vercel-analytics/settings/connections/{key}/test'; + url: '/umbraco/management/api/v1/web-analytics/settings/connections/{key}/test'; }; export type TestConnectionErrors = { diff --git a/src/TheBuilder.WebAnalytics/Client/src/manifests.ts b/src/TheBuilder.WebAnalytics/Client/src/manifests.ts index e24a8e1..0729304 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/manifests.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/manifests.ts @@ -26,7 +26,7 @@ export const manifests: Array = [ name: "Web Analytics Settings Dashboard", js: () => import("./settings/settings-dashboard.element.js"), weight: 25, - meta: { label: "Web Analytics", pathname: "vercel-analytics" }, + meta: { label: "Web Analytics", pathname: "web-analytics" }, conditions: [ { alias: "Umb.Condition.SectionAlias", match: "Umb.Section.Settings" }, { alias: "Umb.Condition.CurrentUser.IsAdmin" }, diff --git a/src/TheBuilder.WebAnalytics/Client/src/section/analytics-availability.ts b/src/TheBuilder.WebAnalytics/Client/src/section/analytics-availability.ts index 2de3e55..d34702a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/section/analytics-availability.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/section/analytics-availability.ts @@ -1,4 +1,4 @@ -export const ANALYTICS_AVAILABILITY_CHANGED_EVENT = "vercel-analytics-availability-changed"; +export const ANALYTICS_AVAILABILITY_CHANGED_EVENT = "web-analytics-availability-changed"; export type AnalyticsAvailabilityChangedDetail = { enabled: boolean; diff --git a/src/TheBuilder.WebAnalytics/Client/src/section/analytics-section.element.ts b/src/TheBuilder.WebAnalytics/Client/src/section/analytics-section.element.ts index a4866c4..73653d7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/section/analytics-section.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/section/analytics-section.element.ts @@ -2,17 +2,17 @@ import { LitElement, customElement, html } from "@umbraco-cms/backoffice/externa import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import "../analytics/analytics-dashboard.element.js"; -@customElement("vercel-analytics-section") -export class VercelAnalyticsSectionElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-section") +export class WebAnalyticsSectionElement extends UmbElementMixin(LitElement) { render() { - return html``; + return html``; } } -export default VercelAnalyticsSectionElement; +export default WebAnalyticsSectionElement; declare global { interface HTMLElementTagNameMap { - "vercel-analytics-section": VercelAnalyticsSectionElement; + "web-analytics-section": WebAnalyticsSectionElement; } } 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 5050769..6dcb2a1 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts @@ -9,27 +9,35 @@ import { import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement, UUIToggleElement } from "@umbraco-cms/backoffice/external/uui"; -import type { AnalyticsConnectionSettingsResponse } from "../api/types.gen.js"; +import type { AnalyticsConnectionSettingsResponse, AnalyticsProviderDescriptor } from "../api/types.gen.js"; import type { ConnectionValidationErrors } from "./settings-model.js"; import { parseTeamReference, teamReference } from "./settings-model.js"; import { getMockScenario } from "./mock-scenarios.js"; +import { identifierField, identifierValue, providerLogo } from "./provider-identity.js"; import "@umbraco-cms/backoffice/document"; export type EditableAnalyticsConnection = AnalyticsConnectionSettingsResponse; export type ConnectionActionStatus = { type: "success" | "error" | "info"; message: string }; -@customElement("vercel-analytics-connection-editor") -export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-connection-editor") +export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) connection!: EditableAnalyticsConnection; + @property({ attribute: false }) descriptor?: AnalyticsProviderDescriptor; @property({ attribute: false }) errors: ConnectionValidationErrors = {}; @property({ attribute: false }) status?: ConnectionActionStatus; @property({ type: Boolean }) mockConnectionsEnabled = false; @property({ type: Boolean }) dirty = false; @property({ type: Boolean }) testing = false; - @state() private _tokenCopied = false; + @state() private _tokenCopyStatus?: "copied" | "failed"; + private _copyStatusTimer?: number; + + disconnectedCallback(): void { + window.clearTimeout(this._copyStatusTimer); + super.disconnectedCallback(); + } protected firstUpdated(): void { - if (!this.connection.projectId) { + if (!this.descriptor || !identifierValue(this.connection, this.descriptor)) { this.shadowRoot?.querySelector(".connection-shell")?.setAttribute("open", ""); } } @@ -68,6 +76,19 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE this.#update(parseTeamReference(String((event.target as UUIInputElement).value ?? ""))); } + #eventPropertyNames(event: Event): void { + const names = String((event.target as HTMLElement & { value?: string }).value ?? "") + .split(/\r?\n/) + .map((name) => name.trim()) + .filter((name, index, values) => name && values.findIndex((candidate) => candidate.toLocaleLowerCase() === name.toLocaleLowerCase()) === index); + this.#update({ eventPropertyNames: names }); + } + + #eventPropertySummary(): string { + const count = this.connection.eventPropertyNames.length; + return count ? `${count} custom propert${count === 1 ? "y" : "ies"}` : "Built-in properties only"; + } + #mappingSummary(): string { const roots = this.connection.documentRootKeys.length; return roots ? `${roots} document root${roots === 1 ? "" : "s"}` : "Global analytics only"; @@ -80,9 +101,14 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE } async #copyTokenKey(): Promise { - await navigator.clipboard.writeText(`WebAnalytics__ConnectionAccessTokens__${this.connection.key}`); - this._tokenCopied = true; - window.setTimeout(() => { this._tokenCopied = false; }, 2000); + window.clearTimeout(this._copyStatusTimer); + try { + await navigator.clipboard.writeText(`WebAnalytics__ConnectionAccessTokens__${this.connection.key}`); + this._tokenCopyStatus = "copied"; + } catch { + this._tokenCopyStatus = "failed"; + } + this._copyStatusTimer = window.setTimeout(() => { this._tokenCopyStatus = undefined; }, 3000); } #dispatch(name: "test-connection" | "remove-connection"): void { @@ -91,37 +117,60 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE render() { const connection = this.connection; + const descriptor = this.descriptor; + if (!descriptor) return this.#renderUnsupportedProvider(); + const field = identifierField(descriptor); + const identifier = identifierValue(connection, descriptor); + if (!field || identifier === undefined) return this.#renderUnsupportedProvider(); const isMock = connection.mockScenario != null; const mockScenario = getMockScenario(connection.mockScenario); const testHint = this.dirty ? "Save changes before testing this connection." : isMock && !this.mockConnectionsEnabled ? "Mock connections are only active in Development." + : !identifier + ? `Enter the ${descriptor.identifier.label} before testing.` + : !connection.hasAccessToken + ? "Add a server-side credential before testing this connection." : "Test the saved connection."; const tokenStatus = isMock ? this.mockConnectionsEnabled ? "Development mock" : "Inactive mock" : connection.hasAccessTokenOverride - ? "Token override" + ? "Credential override" : connection.hasAccessToken - ? "Shared token" - : "Token missing"; - const tokenColor = isMock - ? this.mockConnectionsEnabled ? "positive" : "warning" - : connection.hasAccessToken ? "positive" : "warning"; + ? "Shared credential" + : "Credential missing"; + const health = this.testing + ? { label: "Testing", color: undefined } + : this.status?.type === "success" + ? { label: "Connected", color: "positive" as const } + : this.status?.type === "error" + ? { label: "Needs attention", color: "danger" as const } + : isMock + ? this.mockConnectionsEnabled + ? { label: "Ready", color: "positive" as const } + : { label: "Inactive", color: "warning" as const } + : !identifier || !connection.hasAccessToken + ? { label: "Setup required", color: "warning" as const } + : { label: "Not tested", color: undefined }; const testDisabled = this.testing || this.dirty - || (isMock ? !this.mockConnectionsEnabled : !connection.projectId); + || (isMock ? !this.mockConnectionsEnabled : !identifier || !connection.hasAccessToken); return html`
+ ${isMock ? html`` : providerLogo(descriptor)} - ${connection.displayName || connection.projectId || "New connection"} - ${isMock ? "Mock scenario" : connection.projectId || "Project ID required"} · ${this.#mappingSummary()} + ${connection.displayName || identifier || "New connection"} + ${isMock ? "Mock scenario" : `${connection.provider} · ${identifier || "Identifier required"}`} · ${this.#mappingSummary()} - ${tokenStatus} - + + ${health.color ? html`${health.label}` : html`${health.label}`} + + + ${tokenStatus} @@ -129,7 +178,7 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE
-

${isMock ? "Mock data" : "Project"}

+

${isMock ? "Mock data" : `${connection.provider} connection`}

${this.status ? html`${this.status.message}` : ""} @@ -142,36 +191,69 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE .state=${this.testing ? "waiting" : undefined} ?disabled=${testDisabled} @click=${() => this.#dispatch("test-connection")}>Test connection - this.#dispatch("remove-connection")}>Remove + this.#dispatch("remove-connection")}>Delete
${isMock ? html` -

${mockScenario?.description ?? "Deterministic analytics data."} This connection never contacts Vercel.

+

${mockScenario?.description ?? "Deterministic analytics data."} This connection never contacts an analytics provider.

` : html`
- ${this.#field("Vercel project ID", "projectId", connection.projectId, undefined, this.errors.projectId)} - ${this.#teamReferenceField(teamReference(connection), this.errors.team)} + ${this.#field(descriptor.identifier.label, field, identifier, descriptor.identifier.description, this.errors[field])} + ${descriptor.team ? this.#teamReferenceField(descriptor.team, teamReference(connection), this.errors.team) : ""}
`}
${isMock ? "" : html`
- Token override${connection.hasAccessTokenOverride ? "Configured on the server" : connection.hasAccessToken ? "Using shared token" : "Optional"} + Credential override${connection.hasAccessTokenOverride ? "Configured on the server" : connection.hasAccessToken ? "Using shared credential" : "Required without a shared credential"}

- Optional. Set a connection-specific token only when this project cannot use the shared token. - - Create a Vercel access token + ${connection.hasAccessToken + ? `Set a connection-specific credential only when this connection cannot use the shared ${connection.provider} ${descriptor.credential.label}.` + : `No shared ${connection.provider} ${descriptor.credential.label} was detected. Add a connection-specific credential before testing this connection.`} + + Create a ${connection.provider} ${descriptor.credential.label}

-
WebAnalytics__ConnectionAccessTokens__${connection.key}${this._tokenCopied ? "Copied" : "Copy"}
+
+ WebAnalytics__ConnectionAccessTokens__${connection.key} + ${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." : ""} +
`} + ${!isMock && descriptor.eventProperties ? html` +
+ Event properties${this.#eventPropertySummary()} +
+

${descriptor.eventProperties.description} Add one name per line. The standard url and path properties are included automatically for matching events.

+ + Custom property names +
+ + + ${this.errors.eventPropertyNames ? html`${this.errors.eventPropertyNames}` : `Up to ${descriptor.eventProperties.maximumNames} names.`} + +
+
+
+
+ ` : ""} +
Page analytics${this.#mappingSummary()}
-

Optional. Select the Umbraco site roots that use this Vercel project. Leave empty for global analytics only.

+

Optional. Select the Umbraco site roots that use this analytics connection. Leave empty for global analytics only.

Document roots @@ -203,7 +285,7 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE #field( label: string, - field: "projectId", + field: "projectId" | "siteId", value: string, description?: string, error?: string, @@ -222,23 +304,29 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE `; } - #teamReferenceField(value: string, error?: string) { + #renderUnsupportedProvider() { + return html` +

Unsupported analytics provider: ${this.connection.provider}. This connection cannot be saved or tested until the server returns a supported provider descriptor.

+
`; + } + + #teamReferenceField(descriptor: NonNullable, value: string, error?: string) { const id = `${this.connection.key}-team-reference`; return html` - Team ID or slug + ${descriptor.label}
- ${error ? html`${error}` : "Leave empty for a personal project."} + ${error ? html`${error}` : descriptor.description}
@@ -251,23 +339,30 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE details, summary { box-sizing: border-box; } summary { cursor: pointer; list-style: none; } summary::-webkit-details-marker { display: none; } - .connection-summary { align-items: center; appearance: none; background: transparent; border: 0; color: inherit; cursor: pointer; display: flex; font: inherit; gap: var(--uui-size-space-5); inline-size: 100%; justify-content: space-between; min-block-size: 4rem; padding: var(--uui-size-space-4) var(--uui-size-space-5); text-align: start; } + .connection-summary { align-items: center; appearance: none; background: transparent; border: 0; color: inherit; cursor: pointer; display: grid; font: inherit; gap: var(--uui-size-space-4); grid-template-columns: auto minmax(0, 1fr) auto; inline-size: 100%; min-block-size: 4rem; padding: var(--uui-size-space-4) var(--uui-size-space-5); text-align: start; } .connection-summary:hover { background: color-mix(in srgb, var(--uui-color-interactive) 3%, var(--uui-color-surface)); } .connection-summary:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } .summary-copy { display: grid; gap: var(--uui-size-space-1); min-inline-size: 0; } .summary-copy strong { font-size: var(--uui-type-h5-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .summary-copy > span { color: var(--uui-color-text-alt); overflow-wrap: anywhere; } - .summary-state { align-items: center; display: flex; flex: 0 0 auto; gap: var(--uui-size-space-3); } - .connection-shell[open] .summary-state > uui-icon { transform: rotate(180deg); } + .provider-mark { align-items: center; background: var(--uui-color-surface-alt); block-size: var(--uui-size-8); color: var(--uui-color-text); display: inline-flex; inline-size: var(--uui-size-8); justify-content: center; } + .provider-logo { block-size: var(--uui-size-5); display: block; inline-size: var(--uui-size-5); } + .provider-mark > uui-icon { font-size: var(--uui-size-5); } + .summary-state { align-items: end; display: grid; gap: var(--uui-size-space-1); justify-items: end; min-inline-size: 0; } + .summary-state small { color: var(--uui-color-text-alt); max-inline-size: 24ch; overflow-wrap: anywhere; text-align: end; } + .summary-health { align-items: center; display: flex; gap: var(--uui-size-space-3); } + .summary-health > uui-icon { transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1); } + .connection-shell[open] .summary-health > uui-icon { transform: rotate(180deg); } .connection-body { border-top: 1px solid var(--uui-color-border); padding: 0 var(--uui-size-space-5) var(--uui-size-space-4); } .essentials { padding: var(--uui-size-space-4) 0 var(--uui-size-space-5); } - .essentials-header { align-items: center; display: grid; gap: var(--uui-size-space-4); grid-template-areas: "heading status actions"; grid-template-columns: auto minmax(0, 1fr) auto; margin-block-end: var(--uui-size-space-4); } + .essentials-header { align-items: center; display: grid; gap: var(--uui-size-space-4); grid-template-areas: "heading actions"; grid-template-columns: minmax(0, 1fr) auto; margin-block-end: var(--uui-size-space-4); } + .essentials-header.has-status { grid-template-areas: "heading actions" "status status"; } .essentials-heading { grid-area: heading; min-inline-size: 0; } .essentials-heading h3 { font-size: var(--uui-type-h5-size); margin: 0; } .connection-actions { align-items: center; display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: var(--uui-size-space-3); grid-area: actions; justify-content: flex-end; } - .action-status { grid-area: status; justify-self: end; min-inline-size: 0; } + .action-status { grid-area: status; justify-self: start; min-inline-size: 0; } .action-status:empty { display: none; } - .action-status span { align-items: center; display: flex; gap: var(--uui-size-space-2); max-inline-size: 48ch; overflow-wrap: anywhere; text-align: end; } + .action-status span { align-items: center; display: flex; gap: var(--uui-size-space-2); max-inline-size: 70ch; overflow-wrap: anywhere; text-align: start; } .action-status .success { color: var(--uui-color-positive-standalone); } .action-status .error { color: var(--uui-color-danger-standalone); } .action-status .info { color: var(--uui-color-text-alt); } @@ -291,26 +386,26 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE .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; } .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); } code { font-family: var(--uui-font-monospace); overflow-wrap: anywhere; } .mapping-content .fields { max-inline-size: 32rem; } + .event-properties-content > uui-form-layout-item { margin-top: 0; max-inline-size: 32rem; } .document-types { margin-top: var(--uui-size-space-4); } .toggle-help { margin-bottom: 0; } .field-control { display: grid; gap: var(--uui-size-space-2); } .field-description { color: var(--uui-color-text); font-size: var(--uui-type-small-size); } .field-error { align-items: center; color: var(--uui-color-danger-standalone); display: flex; gap: var(--uui-size-space-1); } - uui-input { inline-size: 100%; } - uui-input[aria-invalid="true"] { --uui-color-border: var(--uui-color-danger); } + uui-input, uui-textarea { inline-size: 100%; } + uui-input[aria-invalid="true"], uui-textarea[aria-invalid="true"] { --uui-color-border: var(--uui-color-danger); } @container (max-width: 48rem) { .two-columns { grid-template-columns: 1fr; } - .connection-summary { align-items: flex-start; } - .summary-state { flex-wrap: wrap; justify-content: flex-end; } - .essentials-header { grid-template-areas: "heading actions"; grid-template-columns: minmax(0, 1fr) auto; } - .essentials-header.has-status { grid-template-areas: "heading actions" "status status"; } - .action-status { justify-self: start; } - .action-status span { text-align: start; } + .connection-summary { align-items: start; } } @container (max-width: 34rem) { - .connection-summary { align-items: stretch; flex-direction: column; } + .connection-summary { align-items: start; grid-template-columns: auto minmax(0, 1fr); } + .summary-state { grid-column: 2; justify-items: start; } + .summary-state small { text-align: start; } .essentials-header { align-items: stretch; grid-template-areas: "heading" "actions"; grid-template-columns: 1fr; } .essentials-header.has-status { grid-template-areas: "heading" "status" "actions"; } .summary-state { justify-content: flex-start; } @@ -319,11 +414,14 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE .config-section > summary small { margin-inline-start: 0; } .token-key { align-items: stretch; flex-direction: column; } } + @media (prefers-reduced-motion: reduce) { + .summary-health > uui-icon { transition: none; } + } `]; } declare global { interface HTMLElementTagNameMap { - "vercel-analytics-connection-editor": VercelAnalyticsConnectionEditorElement; + "web-analytics-connection-editor": AnalyticsConnectionEditorElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/provider-identity.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/provider-identity.ts new file mode 100644 index 0000000..0cd2b85 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/provider-identity.ts @@ -0,0 +1,51 @@ +import { html } from "@umbraco-cms/backoffice/external/lit"; +import type { + AnalyticsConnectionSettingsResponse, + AnalyticsProvider, + AnalyticsProviderDescriptor, + AnalyticsSettingsResponse, +} from "../api/types.gen.js"; + +export type ConnectionIdentifierField = "projectId" | "siteId"; + +export function providerDescriptor( + settings: Pick, + provider: AnalyticsProvider, +): AnalyticsProviderDescriptor | undefined { + const descriptor = settings.providers.find((item) => item.provider === provider); + return descriptor && isSupportedProviderDescriptor(descriptor) ? descriptor : undefined; +} + +export function identifierValue( + connection: AnalyticsConnectionSettingsResponse, + descriptor: AnalyticsProviderDescriptor, +): string | undefined { + return descriptor.identifier.key === "projectId" + ? connection.projectId + : descriptor.identifier.key === "siteId" + ? connection.siteId + : undefined; +} + +export function identifierField(descriptor: AnalyticsProviderDescriptor): ConnectionIdentifierField | undefined { + return descriptor.identifier.key === "projectId" || descriptor.identifier.key === "siteId" + ? descriptor.identifier.key + : undefined; +} + +export function providerLogo(descriptor: Pick) { + return html``; +} + +function isSupportedProviderDescriptor(descriptor: AnalyticsProviderDescriptor): boolean { + return /^[a-z0-9-]+$/.test(descriptor.logoSlug) + && (descriptor.identifier.key === "projectId" || descriptor.identifier.key === "siteId"); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts index ca25630..4acf89e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts @@ -11,21 +11,23 @@ import type { UUIInputElement, UUIToggleElement } from "@umbraco-cms/backoffice/ import { WebAnalyticsService } from "../api/sdk.gen.js"; import type { AnalyticsConnectionSettingsResponse, + AnalyticsProviderDescriptor, AnalyticsSettingsResponse, UpdateAnalyticsSettingsRequest, } from "../api/types.gen.js"; import "./connection-editor.element.js"; -import type { ConnectionActionStatus, VercelAnalyticsConnectionEditorElement } from "./connection-editor.element.js"; +import type { ConnectionActionStatus, AnalyticsConnectionEditorElement } from "./connection-editor.element.js"; import { createSettingsUpdate, validateConnection, validateEditableSettings } from "./settings-model.js"; import { announceAnalyticsAvailability } from "../section/analytics-availability.js"; import { MOCK_SCENARIOS, type MockScenarioDefinition } from "./mock-scenarios.js"; +import { identifierField, providerDescriptor, providerLogo } from "./provider-identity.js"; type NewConnection = - | { kind: "vercel"; hasAccessToken: boolean } + | { kind: "provider"; descriptor: AnalyticsProviderDescriptor; hasAccessToken: boolean } | { kind: "mock"; scenario: MockScenarioDefinition }; -@customElement("vercel-analytics-settings-dashboard") -export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-settings-dashboard") +export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitElement) { @state() private _settings?: AnalyticsSettingsResponse; @state() private _loading = true; @state() private _saving = false; @@ -34,7 +36,7 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit @state() private _testingKey?: string; @state() private _status?: { type: "success" | "error"; message: string }; @state() private _connectionStatuses: Record = {}; - @state() private _tokenKeyCopied = false; + @state() private _showProviderPicker = false; connectedCallback(): void { super.connectedCallback(); @@ -72,11 +74,26 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit #updateConnection(index: number, connection: AnalyticsConnectionSettingsResponse): void { if (!this._settings) return; const connections = this._settings.connections.map((item, itemIndex) => itemIndex === index ? connection : item); + const { [connection.key]: _discardedStatus, ...remainingStatuses } = this._connectionStatuses; + this._connectionStatuses = remainingStatuses; this.#patch({ connections }); } - #addConnection(): void { - this.#appendConnection({ kind: "vercel", hasAccessToken: this._settings?.hasAccessToken ?? false }); + #addConnection(descriptor: AnalyticsProviderDescriptor): void { + const hasAccessToken = this._settings?.providerTokens.some((item) => item.provider === descriptor.provider && item.hasAccessToken) ?? false; + this.#appendConnection({ kind: "provider", descriptor, hasAccessToken }); + this._showProviderPicker = false; + } + + #toggleProviderPicker(): void { + this._showProviderPicker = !this._showProviderPicker; + void this.updateComplete.then(() => { + if (this._showProviderPicker) { + this.shadowRoot?.querySelector(".provider-choice")?.focus(); + } else { + this.shadowRoot?.querySelector('[label="Add analytics connection"], [label="Choose analytics provider"]')?.focus(); + } + }); } #addMockConnection(scenario: MockScenarioDefinition): void { @@ -86,12 +103,17 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit #appendConnection(details: NewConnection): void { if (!this._settings) return; const isMock = details.kind === "mock"; + const provider = isMock ? "Vercel" : details.descriptor.provider; + const descriptor = isMock ? providerDescriptor(this._settings, provider) : details.descriptor; const key = crypto.randomUUID(); const connection: AnalyticsConnectionSettingsResponse = { key, displayName: isMock ? details.scenario.displayName : "", + provider, projectId: "", team: null, + siteId: "", + eventPropertyNames: [], documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], @@ -101,26 +123,27 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit }; const connections = [...this._settings.connections, connection]; this.#patch({ connections }); - this.updateComplete.then(() => { + void this.updateComplete.then(async () => { const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - this.shadowRoot?.querySelector("vercel-analytics-connection-editor:last-of-type")?.scrollIntoView({ + const editor = this.shadowRoot?.querySelector("web-analytics-connection-editor:last-of-type"); + editor?.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "start", }); + await editor?.updateComplete; + const fieldName = descriptor && identifierField(descriptor); + if (fieldName) editor?.shadowRoot?.querySelector(`[name="${fieldName}"]`)?.focus(); }); } - async #copyTokenKey(): Promise { - await navigator.clipboard.writeText("WebAnalytics__Providers__Vercel__AccessToken"); - this._tokenKeyCopied = true; - window.setTimeout(() => { this._tokenKeyCopied = false; }, 2000); - } - #removeConnection(index: number): void { if (!this._settings) return; const connection = this._settings.connections[index]; - if (!window.confirm(`Remove “${connection.displayName || connection.projectId || "this connection"}”? This takes effect when settings are saved.`)) return; + if (!connection) return; + if (!window.confirm(`Delete “${connection.displayName || connection.siteId || connection.projectId || "this connection"}” from Web Analytics? The connection is removed when you save settings.`)) return; const connections = this._settings.connections.filter((_, itemIndex) => itemIndex !== index); + const { [connection.key]: _discardedStatus, ...remainingStatuses } = this._connectionStatuses; + this._connectionStatuses = remainingStatuses; this.#patch({ connections }); } @@ -189,180 +212,236 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit } #focusFirstInvalid(): void { - const editors = this.shadowRoot?.querySelectorAll("vercel-analytics-connection-editor") ?? []; + const editors = this.shadowRoot?.querySelectorAll("web-analytics-connection-editor") ?? []; for (const editor of editors) { if (editor.focusFirstInvalid()) return; } } + #renderProviderPicker() { + if (!this._showProviderPicker) return ""; + return html` +
+
+
+

Choose an analytics provider

+

The provider cannot be changed after this connection is created.

+
+ Close +
+
+ ${this._settings?.providers.map((item) => { + const credential = this._settings?.providerTokens.find((token) => token.provider === item.provider); + return html` + + `; + })} +
+
+ `; + } + + #renderGeneralSettings() { + if (!this._settings) return ""; + return html` + +
+ + Package status + this.#patch({ enabled: (event.target as UUIToggleElement).checked })}> + Enable Web Analytics + + + + Default reporting range +
+ this.#patch({ defaultRangeDays: Number((event.target as UUIInputElement).value) })}> + Days shown when editors first open Analytics. +
+
+ + Cache duration +
+ this.#patch({ cacheDuration: String((event.target as UUIInputElement).value) })}> + How long reports stay cached before fresh data is requested. Use hh:mm:ss, for example 00:05:00. +
+
+
+
+ `; + } + + #renderDevelopmentData() { + if (!this._settings?.canCreateMockConnections) return ""; + return html` + +

Create deterministic local connections to check dashboard states without contacting an analytics provider. Mock connections are available only in Development.

+
+ ${MOCK_SCENARIOS.map((scenario) => { + const added = this._settings?.connections.some((connection) => connection.mockScenario === scenario.id) ?? false; + return html` +
+ ${scenario.name}${scenario.description} + this.#addMockConnection(scenario)}>${added ? "Added" : "Add mock"} +
+ `; + })} +
+
+ `; + } + render() { if (this._loading) return html``; if (!this._settings) return html`

${this._status?.message}

Retry
`; - const hasConnections = this._settings.connections.length > 0; - const mockConnectionsEnabled = this._settings.canCreateMockConnections; + const settings = this._settings; + const hasConnections = settings.connections.length > 0; + const mockConnectionsEnabled = settings.canCreateMockConnections; return html`
-
-

Web Analytics

Connect Vercel projects and choose where page analytics appears.

-
- ${this._dirty ? html` -
- Unsaved changes +
+ + + Unsaved changes + Save settings
` : ""} ${this._status ? html`
${this._status.message}
` : ""} - -
- - Package status - this.#patch({ enabled: (event.target as UUIToggleElement).checked })}> - Enable Web Analytics - - - - Default range in days - this.#patch({ defaultRangeDays: Number((event.target as UUIInputElement).value) })}> - - - Cache duration -
- this.#patch({ cacheDuration: String((event.target as UUIInputElement).value) })}> - Use hh:mm:ss, for example 00:05:00. -
-
-
-
- Access token - ${this._settings.hasAccessToken - ? html`Configured` - : html`Not configured`} -
- ${this._settings.hasAccessToken ? "" : html` -
-
-

Set this server environment variable to a Vercel access token.

- Create token -
-
- WebAnalytics__Providers__Vercel__AccessToken - ${this._tokenKeyCopied ? "Copied" : "Copy"} -
-
- `} -
-
-
- - ${this._settings.canCreateMockConnections ? html` - -

Create deterministic local connections to verify dashboard states without calling Vercel. Mock connections are only active while the server runs in Development.

-
- ${MOCK_SCENARIOS.map((scenario) => { - const added = this._settings?.connections.some((connection) => connection.mockScenario === scenario.id) ?? false; - return html` -
- ${scenario.name}${scenario.description} - this.#addMockConnection(scenario)}>${added ? "Added" : "Add mock"} -
- `; - })} -
-
- ` : ""} - -
+
-

Connections

Add each Vercel project that editors should be able to view.

- ${hasConnections ? html` - Add connection - ` : ""} +

Connections

Add each analytics site or project that editors should be able to view.

+ ${hasConnections ? html`Add connection` : ""}
+ ${this.#renderProviderPicker()}
- ${this._settings.connections.map((connection, index) => html` - html` + ) => this.#updateConnection(index, event.detail)} @remove-connection=${() => this.#removeConnection(index)} - @test-connection=${() => this.#testConnection(connection.key)}> + @test-connection=${() => this.#testConnection(connection.key)}> `)} - ${!hasConnections ? html` + ${!hasConnections && !this._showProviderPicker ? html`
-

Connect your first Vercel project

-

Add a Vercel project ID. The shared server-side access token above will be used automatically.

+

Connect your first analytics provider

+

Add the project or site that editors should be able to view. Credentials stay in your server configuration.

- Add connection + Add connection
` : ""}
+ ${this.#renderGeneralSettings()} + ${this.#renderDevelopmentData()} + `; } static styles = [UmbTextStyles, css` - :host { --analytics-z-sticky-action: 10; display: block; } - form { max-width: 76rem; margin-inline: auto; padding: var(--uui-size-layout-1); } - header, .section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--uui-size-layout-1); } - .page-heading, .section-heading > div { min-inline-size: 0; } - .save-bar { + :host { + --analytics-z-sticky-action: 10; + --settings-actions-inline-inset: var(--uui-size-space-4); + --settings-column-max: 76rem; + --settings-inline-gutter: var(--uui-size-layout-1); + container-type: inline-size; + display: block; + } + form { max-width: var(--settings-column-max); margin-inline: auto; padding: var(--uui-size-layout-1) var(--settings-inline-gutter) calc(var(--uui-size-layout-1) + var(--uui-size-14) + var(--uui-size-space-4)); } + .section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--uui-size-layout-1); } + .section-heading > div { min-inline-size: 0; } + .settings-actions { align-items: center; - background: var(--uui-color-surface); - border: 1px solid var(--uui-color-border); + background: var(--uui-color-surface-alt); + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-3); + box-sizing: border-box; display: flex; flex-wrap: wrap; gap: var(--uui-size-space-4); justify-content: flex-end; - margin-block-start: var(--uui-size-space-5); + inset-block-end: var(--uui-size-layout-1); + inset-inline-end: calc(var(--settings-inline-gutter) + var(--settings-actions-inline-inset)); + max-inline-size: calc(100vw - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)); + min-block-size: var(--uui-size-14); padding: var(--uui-size-space-3) var(--uui-size-space-4); - position: sticky; - top: var(--uui-size-space-4); + position: fixed; + width: min( + calc(var(--settings-column-max) - 2 * var(--settings-actions-inline-inset)), + calc(100vw - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)) + ); z-index: var(--analytics-z-sticky-action); } - .unsaved-indicator { align-items: center; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-small-size); gap: var(--uui-size-space-2); white-space: nowrap; } - .unsaved-indicator::before { background: var(--uui-color-warning-standalone); border-radius: 50%; block-size: var(--uui-size-space-3); content: ""; flex: 0 0 auto; inline-size: var(--uui-size-space-3); } - h1, h2 { margin: 0; } - header p, .section-heading p { color: var(--uui-color-text-alt); margin-block: var(--uui-size-space-2) 0; text-wrap: pretty; } + @supports (width: 1cqi) { + .settings-actions { + inset-inline-end: max( + calc(var(--settings-inline-gutter) + var(--settings-actions-inline-inset)), + calc((100cqi - var(--settings-column-max)) / 2 + var(--settings-actions-inline-inset)) + ); + width: min( + calc(var(--settings-column-max) - 2 * var(--settings-actions-inline-inset)), + calc(100cqi - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)) + ); + } + } + .save-status { align-items: center; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-small-size); gap: var(--uui-size-space-2); white-space: nowrap; } + .save-status uui-icon { color: var(--uui-color-warning-standalone); } + h2 { margin: 0; } + .section-heading p { color: var(--uui-color-text-alt); margin-block: var(--uui-size-space-2) 0; text-wrap: pretty; } .status { align-items: flex-start; border: 1px solid var(--uui-color-border); display: flex; gap: var(--uui-size-space-2); margin-block: var(--uui-size-space-5); overflow-wrap: anywhere; padding: var(--uui-size-space-3) var(--uui-size-space-4); } .status.success { background: color-mix(in srgb, var(--uui-color-positive) 8%, var(--uui-color-surface)); border-color: color-mix(in srgb, var(--uui-color-positive) 35%, var(--uui-color-border)); } .status.error { background: color-mix(in srgb, var(--uui-color-danger) 7%, var(--uui-color-surface)); border-color: color-mix(in srgb, var(--uui-color-danger) 35%, var(--uui-color-border)); } - .general { container-type: inline-size; margin-block: var(--uui-size-space-6) var(--uui-size-layout-2); } - .mock-settings { margin-block-end: var(--uui-size-layout-2); } + .connections-section { margin-block-start: 0; } + .general, .mock-settings { margin-block-start: var(--uui-size-layout-2); } + .general { container-type: inline-size; } .mock-intro { color: var(--uui-color-text-alt); margin: 0 0 var(--uui-size-space-5); max-inline-size: 72ch; text-wrap: pretty; } .mock-scenarios { display: grid; gap: var(--uui-size-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); } .mock-scenario { align-items: center; border: 1px solid var(--uui-color-border); display: grid; gap: var(--uui-size-space-4); grid-template-columns: minmax(0, 1fr) max-content; min-inline-size: 0; padding: var(--uui-size-space-4); } @@ -374,19 +453,26 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit .field-with-help { display: grid; gap: var(--uui-size-space-1); } .field-help { color: var(--uui-color-text-alt); font-size: var(--uui-type-small-size); } .package-status { min-inline-size: 0; } - .shared-token { align-items: start; border-top: 1px solid var(--uui-color-border); display: grid; gap: var(--uui-size-space-3); grid-column: 1 / -1; min-inline-size: 0; padding-block-start: var(--uui-size-space-4); } - .shared-token-summary { align-items: center; display: flex; flex-wrap: wrap; gap: var(--uui-size-space-2); } - .shared-token-setup { display: grid; gap: var(--uui-size-space-4); max-inline-size: 42rem; min-inline-size: 0; } - .shared-token-guidance { display: grid; gap: var(--uui-size-space-2); justify-items: start; } - .shared-token-help { color: var(--uui-color-text-alt); margin: 0; max-inline-size: 48ch; text-wrap: pretty; } - .shared-token-status.configured { align-items: center; color: var(--uui-color-positive); display: inline-flex; gap: var(--uui-size-space-1); } - .shared-token-key { align-items: center; background: var(--uui-color-surface-alt); display: flex; gap: var(--uui-size-space-2); max-inline-size: 100%; padding: var(--uui-size-space-2) var(--uui-size-space-3); } - .shared-token-key code { min-inline-size: 0; overflow-wrap: anywhere; } - .shared-token-guidance > a { align-items: center; color: var(--uui-color-interactive); display: inline-flex; gap: var(--uui-size-space-1); white-space: nowrap; } - .shared-token-guidance > a uui-icon { font-size: 0.875em; } .visually-hidden { block-size: 1px; clip: rect(0 0 0 0); clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; } .section-heading { margin-bottom: var(--uui-size-space-4); } - .connections { display: grid; gap: var(--uui-size-layout-1); } + .provider-picker { background: var(--uui-color-surface-alt); border-block: 1px solid var(--uui-color-border); margin-block-end: var(--uui-size-space-5); padding: var(--uui-size-space-4) var(--uui-size-space-5) var(--uui-size-space-5); } + .provider-picker-heading { align-items: flex-start; display: flex; gap: var(--uui-size-space-4); justify-content: space-between; margin-block-end: var(--uui-size-space-4); } + .provider-picker-heading h3 { font-size: var(--uui-type-h5-size); margin: 0; text-wrap: balance; } + .provider-picker-heading p { color: var(--uui-color-text-alt); margin: var(--uui-size-space-1) 0 0; text-wrap: pretty; } + .provider-choices { display: grid; gap: var(--uui-size-space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); } + .provider-choice { align-items: start; appearance: none; background: var(--uui-color-surface); border: 1px solid var(--uui-color-border); color: inherit; cursor: pointer; display: grid; font: inherit; gap: var(--uui-size-space-2) var(--uui-size-space-4); grid-template-columns: auto minmax(0, 1fr); inline-size: 100%; padding: var(--uui-size-space-4); text-align: start; } + .provider-choice:hover { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); } + .provider-choice:active { background: color-mix(in srgb, var(--uui-color-interactive) 7%, var(--uui-color-surface)); } + .provider-choice:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } + .provider-mark { align-items: center; background: var(--uui-color-surface-alt); block-size: var(--uui-size-8); color: var(--uui-color-text); display: inline-flex; inline-size: var(--uui-size-8); justify-content: center; } + .provider-logo { block-size: var(--uui-size-5); display: block; inline-size: var(--uui-size-5); } + .provider-choice-copy { display: grid; gap: var(--uui-size-space-1); min-inline-size: 0; } + .provider-choice-copy strong { font-size: var(--uui-type-h5-size); } + .provider-choice-copy > span { color: var(--uui-color-text-alt); overflow-wrap: anywhere; } + .provider-choice-status { align-items: center; display: inline-flex; font-size: var(--uui-type-small-size); gap: var(--uui-size-space-1); grid-column: 2; white-space: normal; } + .provider-choice-status.configured { color: var(--uui-color-positive-standalone); } + .provider-choice-status.missing { color: var(--uui-color-text-alt); } + .connections { display: grid; gap: var(--uui-size-space-5); } .connection-empty-state { align-items: center; background: color-mix(in srgb, var(--uui-color-interactive) 5%, var(--uui-color-surface)); @@ -407,26 +493,26 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit @container (max-width: 34rem) { .general-grid { grid-template-columns: 1fr; } .package-status { grid-column: auto; } - .shared-token { grid-column: auto; } - .shared-token-key { justify-content: space-between; } } @media (max-width: 800px) { - header, .section-heading { align-items: stretch; flex-direction: column; } + .section-heading { align-items: stretch; flex-direction: column; } .mock-scenarios { grid-template-columns: 1fr; } - .save-bar { top: var(--uui-size-space-2); } + .settings-actions { inset-block-end: var(--uui-size-space-4); } .connection-empty-state { align-items: start; grid-template-columns: auto minmax(0, 1fr); } .connection-empty-state uui-button { grid-column: 1 / -1; justify-self: start; } + .provider-choices { grid-template-columns: 1fr; } + .provider-picker-heading { align-items: stretch; flex-direction: column; } } @media (forced-colors: active) { - .unsaved-indicator::before { background: Highlight; } + .save-status uui-icon { color: Highlight; } } `]; } -export default VercelAnalyticsSettingsDashboardElement; +export default WebAnalyticsSettingsDashboardElement; declare global { interface HTMLElementTagNameMap { - "vercel-analytics-settings-dashboard": VercelAnalyticsSettingsDashboardElement; + "web-analytics-settings-dashboard": WebAnalyticsSettingsDashboardElement; } } 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 3ec3d56..d364dd0 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -15,12 +15,16 @@ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); vi.mock("@umbraco-cms/backoffice/document", () => ({})); import type { AnalyticsConnectionSettingsResponse, AnalyticsSettingsResponse } from "../api/types.gen.js"; -import type { VercelAnalyticsSettingsDashboardElement } from "./settings-dashboard.element.js"; -import type { VercelAnalyticsConnectionEditorElement } from "./connection-editor.element.js"; +import type { WebAnalyticsSettingsDashboardElement } from "./settings-dashboard.element.js"; +import type { AnalyticsConnectionEditorElement } from "./connection-editor.element.js"; import "./settings-dashboard.element.js"; beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); sdk.settings.mockResolvedValue(apiOk(settings())); }); @@ -34,7 +38,7 @@ describe("analytics settings network recovery", () => { sdk.settings.mockRejectedValueOnce(new Error("Network unavailable")); sdk.settings.mockResolvedValueOnce(apiOk(settings())); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("umb-empty-state")).not.toBeNull()); @@ -42,7 +46,7 @@ describe("analytics settings network recovery", () => { dashboard.shadowRoot?.querySelector('[label="Retry loading settings"]')?.click(); - await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".page-heading")).not.toBeNull()); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".connections-section")).not.toBeNull()); expect(sdk.settings).toHaveBeenCalledTimes(2); }); @@ -53,11 +57,11 @@ describe("analytics settings network recovery", () => { arrangeResponse(); sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection()] }))); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); - await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("vercel-analytics-connection-editor")).not.toBeNull()); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-connection-editor")).not.toBeNull()); - const editor = dashboard.shadowRoot?.querySelector("vercel-analytics-connection-editor") as VercelAnalyticsConnectionEditorElement; + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; editor.dispatchEvent(new CustomEvent("test-connection", { bubbles: true, composed: true })); await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".action-status")?.textContent).toContain("The connection test could not be completed.")); @@ -65,10 +69,24 @@ describe("analytics settings network recovery", () => { expect(editor.shadowRoot?.querySelector('[label="Test the saved connection."]')?.hasAttribute("disabled")).toBe(false); }); + it("promotes a successful connection test into the connection summary", async () => { + sdk.testConnection.mockResolvedValueOnce(apiOk({ success: true, message: "Connection successful." })); + sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection()] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + 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.dispatchEvent(new CustomEvent("test-connection", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".summary-health uui-tag")?.textContent?.trim()).toBe("Connected")); + expect(editor.shadowRoot?.querySelector(".action-status")?.textContent).toContain("Connection successful."); + }); + it("keeps edits dirty after a rejected save and allows a later save to succeed", async () => { sdk.saveSettings.mockRejectedValueOnce(new Error("Network unavailable")); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("#default-range")).not.toBeNull()); @@ -81,7 +99,7 @@ describe("analytics settings network recovery", () => { form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true })); await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Settings were not saved.")); - expect(dashboard.shadowRoot?.querySelector(".save-bar")).not.toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent).toContain("Unsaved changes"); expect(input!.value).toBe("31"); expect(dashboard.shadowRoot?.querySelector('[label="Save Web Analytics settings"]')?.hasAttribute("disabled")).toBe(false); @@ -89,14 +107,14 @@ describe("analytics settings network recovery", () => { form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true })); await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Web Analytics settings saved.")); - expect(dashboard.shadowRoot?.querySelector(".save-bar")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".settings-actions")).toBeNull(); expect(sdk.saveSettings).toHaveBeenCalledTimes(2); }); it("shows the same save error for an SDK error result", async () => { sdk.saveSettings.mockResolvedValueOnce(apiError()); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("#default-range")).not.toBeNull()); @@ -107,7 +125,7 @@ describe("analytics settings network recovery", () => { dashboard.shadowRoot?.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true })); await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Settings were not saved.")); - expect(dashboard.shadowRoot?.querySelector(".save-bar")).not.toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent).toContain("Unsaved changes"); expect(input!.value).toBe("31"); }); }); @@ -115,21 +133,46 @@ describe("analytics settings network recovery", () => { function settings(overrides: Partial = {}): AnalyticsSettingsResponse { return { enabled: true, - hasAccessToken: false, + providerTokens: [{ provider: "Vercel", hasAccessToken: false }, { provider: "Plausible", hasAccessToken: false }], canCreateMockConnections: false, defaultRangeDays: 30, cacheDuration: "00:05:00", connections: [], ...overrides, + providers: overrides.providers ?? PROVIDERS, }; } +const PROVIDERS: AnalyticsSettingsResponse["providers"] = [ + { + provider: "Vercel", + description: "Projects using Vercel Web Analytics", + logoSlug: "vercel", + identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, + team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, + credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, + eventProperties: null, + }, + { + provider: "Plausible", + description: "Sites using Plausible Analytics", + logoSlug: "plausible", + identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, + team: null, + credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, + eventProperties: { label: "event properties", description: "Optional custom event property names configured for this Plausible site.", maximumNames: 20, maximumNameLength: 100 }, + }, +]; + function connection(): AnalyticsConnectionSettingsResponse { return { key: "connection-1", displayName: "Example project", + provider: "Vercel", projectId: "prj_example", team: null, + siteId: "", + eventPropertyNames: [], documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], @@ -140,94 +183,155 @@ function connection(): AnalyticsConnectionSettingsResponse { } describe("analytics settings onboarding", () => { + it("shows an unsupported connection and blocks saving when its descriptor is missing", async () => { + sdk.settings.mockResolvedValueOnce(apiOk(settings({ providers: [], connections: [connection()] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-connection-editor")?.shadowRoot?.querySelector(".unsupported-provider") ?? null).not.toBeNull()); + + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; + expect(editor.shadowRoot?.textContent ?? "").toContain("Unsupported analytics provider: Vercel."); + dashboard.shadowRoot?.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true })); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".status")?.textContent).toContain("Unsupported analytics provider: Vercel.")); + expect(sdk.saveSettings).not.toHaveBeenCalled(); + }); + it("guides the first connection without rendering an empty default selector", async () => { - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".connection-empty-state")).not.toBeNull()); expect(dashboard.shadowRoot?.querySelector("#default-connection")).toBeNull(); - expect(dashboard.shadowRoot?.querySelector(".save-bar")).toBeNull(); - expect(dashboard.shadowRoot?.querySelector(".connection-empty-state h3")?.textContent).toBe("Connect your first Vercel project"); + expect(dashboard.shadowRoot?.querySelector("h1")).toBeNull(); + expect(dashboard.shadowRoot?.textContent).not.toContain("Connect analytics providers and choose where page analytics appears."); + expect(dashboard.shadowRoot?.querySelector(".settings-actions")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".connection-empty-state h3")?.textContent).toBe("Connect your first analytics provider"); + + dashboard.shadowRoot?.querySelector('.connection-empty-state [label="Choose analytics provider"]')?.click(); + await dashboard.updateComplete; - dashboard.shadowRoot?.querySelector(".connection-empty-state uui-button")?.click(); + const choices = dashboard.shadowRoot?.querySelectorAll(".provider-choice"); + expect(choices).toHaveLength(2); + expect(choices?.[0].querySelector(".provider-logo.vercel")).not.toBeNull(); + expect(choices?.[1].querySelector(".provider-logo.plausible")).not.toBeNull(); + const plausibleLogo = choices?.[1].querySelector(".provider-logo.plausible"); + expect(plausibleLogo?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/providers/plausible.svg"); + expect(plausibleLogo?.getAttribute("alt")).toBe(""); + expect(plausibleLogo?.width).toBe(24); + expect(plausibleLogo?.height).toBe(24); + choices?.[0].click(); await dashboard.updateComplete; expect(dashboard.shadowRoot?.querySelector(".connection-empty-state")).toBeNull(); expect(dashboard.shadowRoot?.querySelector("#default-connection")).toBeNull(); - const editor = dashboard.shadowRoot?.querySelector("vercel-analytics-connection-editor"); + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor"); expect(editor).not.toBeNull(); - const generatedKey = (editor as VercelAnalyticsConnectionEditorElement).connection.key; + 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(dashboard.shadowRoot?.querySelector(".unsaved-indicator")?.textContent?.trim()).toBe("Unsaved changes"); - expect(dashboard.shadowRoot?.querySelector(".save-bar")).not.toBeNull(); + 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); expect(Array.from(editor?.shadowRoot?.querySelectorAll(".essentials uui-input") ?? []).map((input) => input.getAttribute("name"))).toEqual([ "projectId", "teamReference", ]); - - dashboard.shadowRoot?.querySelector(".section-heading > uui-button")?.click(); + dashboard.shadowRoot?.querySelector('.section-heading [label="Add analytics connection"]')?.click(); + await dashboard.updateComplete; + dashboard.shadowRoot?.querySelector('.provider-choice[aria-label="Add Plausible connection"]')?.click(); await dashboard.updateComplete; - const editors = dashboard.shadowRoot?.querySelectorAll("vercel-analytics-connection-editor"); - expect((editors?.[1] as VercelAnalyticsConnectionEditorElement).connection.key).not.toBe( - (editors?.[0] as VercelAnalyticsConnectionEditorElement).connection.key, + const editors = dashboard.shadowRoot?.querySelectorAll("web-analytics-connection-editor"); + expect((editors?.[1] as AnalyticsConnectionEditorElement).connection.key).not.toBe( + (editors?.[0] as AnalyticsConnectionEditorElement).connection.key, ); + expect((editors?.[1] as AnalyticsConnectionEditorElement).connection.provider).toBe("Plausible"); }); - it("shows the shared token setting before a connection is added", async () => { - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + it("shows provider readiness in the connection picker without exposing server configuration keys", async () => { + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); - await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".shared-token")).not.toBeNull()); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".connection-empty-state")).not.toBeNull()); + dashboard.shadowRoot?.querySelector('.connection-empty-state [label="Choose analytics provider"]')?.click(); + await dashboard.updateComplete; - expect(dashboard.shadowRoot?.querySelector(".shared-token code")?.textContent).toBe("WebAnalytics__Providers__Vercel__AccessToken"); - expect(dashboard.shadowRoot?.querySelector(".shared-token-status")?.textContent?.trim()).toBe("Not configured"); - expect(dashboard.shadowRoot?.querySelector(".shared-token-help")?.textContent?.trim()).toBe( - "Set this server environment variable to a Vercel access token.", - ); - expect(dashboard.shadowRoot?.querySelector(".shared-token-guidance")?.lastElementChild?.tagName).toBe("A"); - expect(dashboard.shadowRoot?.querySelector(".shared-token")?.textContent).not.toContain("Used by all connections"); + const providers = dashboard.shadowRoot?.querySelectorAll(".provider-choice"); + expect(providers).toHaveLength(2); + expect(providers?.[0].textContent).toContain("No shared credential detected"); + expect(providers?.[1].textContent).toContain("No shared credential detected"); + expect(dashboard.shadowRoot?.textContent).not.toContain("WebAnalytics__Providers__"); + expect(dashboard.shadowRoot?.querySelector(".providers")).toBeNull(); }); it("marks new connections as using the configured shared token", async () => { sdk.settings.mockResolvedValue(apiOk({ enabled: true, - hasAccessToken: true, + providers: PROVIDERS, + providerTokens: [{ provider: "Vercel", hasAccessToken: true }, { provider: "Plausible", hasAccessToken: false }], canCreateMockConnections: false, defaultRangeDays: 30, cacheDuration: "00:05:00", connections: [], })); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".connection-empty-state")).not.toBeNull()); - expect(dashboard.shadowRoot?.querySelector(".shared-token-status")?.textContent?.trim()).toBe("Configured"); - expect(dashboard.shadowRoot?.querySelector(".shared-token-setup")).toBeNull(); - expect(dashboard.shadowRoot?.querySelector(".shared-token code")).toBeNull(); - expect(dashboard.shadowRoot?.querySelector('.shared-token a[href*="vercel.com/account/settings/tokens"]')).toBeNull(); - expect(dashboard.shadowRoot?.querySelector('[label="Copy shared access token setting name"]')).toBeNull(); - dashboard.shadowRoot?.querySelector(".connection-empty-state uui-button")?.click(); + dashboard.shadowRoot?.querySelector('.connection-empty-state [label="Choose analytics provider"]')?.click(); + await dashboard.updateComplete; + const vercelProvider = dashboard.shadowRoot?.querySelectorAll(".provider-choice")[0]; + expect(vercelProvider?.textContent).toContain("Shared credential detected"); + expect(vercelProvider?.querySelector("code")).toBeNull(); + dashboard.shadowRoot?.querySelector('.provider-choice[aria-label="Add Vercel connection"]')?.click(); await dashboard.updateComplete; - const editor = dashboard.shadowRoot?.querySelector("vercel-analytics-connection-editor") as VercelAnalyticsConnectionEditorElement; + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; expect(editor.connection.hasAccessToken).toBe(true); expect(editor.connection.hasAccessTokenOverride).toBe(false); - expect(editor.shadowRoot?.querySelector(".summary-state uui-tag")?.textContent?.trim()).toBe("Shared token"); + expect(editor.shadowRoot?.querySelector(".summary-state uui-tag")?.textContent?.trim()).toBe("Setup required"); + expect(editor.shadowRoot?.querySelector(".summary-state small")?.textContent?.trim()).toBe("Shared credential"); + }); + + it("prevents testing until both the identifier and server credential are available", async () => { + sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [{ ...connection(), hasAccessToken: false }] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-connection-editor")).not.toBeNull()); + + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; + const testButton = editor.shadowRoot?.querySelector('[label="Add a server-side credential before testing this connection."]'); + expect(testButton?.hasAttribute("disabled")).toBe(true); + expect(editor.shadowRoot?.querySelector(".summary-health uui-tag")?.textContent?.trim()).toBe("Setup required"); + }); + + it("shows a recoverable message when the override setting name cannot be copied", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard unavailable")) }, + }); + sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection()] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + 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(); + + await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".copy-feedback")?.textContent).toContain("Select and copy it manually")); }); it("adds development mock scenarios as deterministic connections", async () => { sdk.settings.mockResolvedValue(apiOk({ enabled: true, - hasAccessToken: false, + providers: PROVIDERS, + providerTokens: [{ provider: "Vercel", hasAccessToken: false }, { provider: "Plausible", hasAccessToken: false }], canCreateMockConnections: true, defaultRangeDays: 30, cacheDuration: "00:05:00", connections: [], })); - const dashboard = document.createElement("vercel-analytics-settings-dashboard") as VercelAnalyticsSettingsDashboardElement; + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; document.body.append(dashboard); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".mock-settings")).not.toBeNull()); @@ -237,14 +341,15 @@ describe("analytics settings onboarding", () => { buttons?.[1].click(); await dashboard.updateComplete; - const editor = dashboard.shadowRoot?.querySelector("vercel-analytics-connection-editor") as VercelAnalyticsConnectionEditorElement; + const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; expect(editor.connection).toMatchObject({ displayName: "Mock · UTM campaigns", projectId: "", hasAccessToken: false, mockScenario: "Utm", }); - expect(editor.shadowRoot?.querySelector(".summary-state uui-tag")?.textContent?.trim()).toBe("Development mock"); + expect(editor.shadowRoot?.querySelector(".summary-state uui-tag")?.textContent?.trim()).toBe("Ready"); + expect(editor.shadowRoot?.querySelector(".summary-state small")?.textContent?.trim()).toBe("Development mock"); expect(editor.shadowRoot?.querySelector(".token-section")).toBeNull(); expect(buttons?.[1].hasAttribute("disabled")).toBe(true); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts index 4ab3fec..4460cdd 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts @@ -7,18 +7,42 @@ import { validateConnection, validateEditableSettings, } from "./settings-model.js"; +import { providerDescriptor } from "./provider-identity.js"; const settings = (): AnalyticsSettingsResponse => ({ enabled: true, - hasAccessToken: false, + providers: [ + { + provider: "Vercel", + description: "Projects using Vercel Web Analytics", + logoSlug: "vercel", + identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, + team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, + credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, + eventProperties: null, + }, + { + provider: "Plausible", + description: "Sites using Plausible Analytics", + logoSlug: "plausible", + identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, + team: null, + credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, + eventProperties: { label: "event properties", description: "Optional custom event property names configured for this Plausible site.", maximumNames: 20, maximumNameLength: 100 }, + }, + ], + providerTokens: [{ provider: "Vercel", hasAccessToken: false }, { provider: "Plausible", hasAccessToken: false }], canCreateMockConnections: false, defaultRangeDays: 30, cacheDuration: "00:05:00", connections: [{ key: "11111111-1111-1111-1111-111111111111", displayName: "Main", + provider: "Vercel", projectId: "project", team: null, + siteId: "", + eventPropertyNames: [], documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], @@ -33,8 +57,8 @@ describe("analytics settings model", () => { const connection = settings().connections[0]; connection.projectId = ""; - expect(validateConnection(connection)).toEqual({ - projectId: "Enter the Vercel project ID.", + expect(validateConnection(connection, providerDescriptor(settings(), connection.provider))).toEqual({ + projectId: "Enter a Vercel project ID.", }); }); @@ -43,7 +67,7 @@ describe("analytics settings model", () => { model.connections[0].projectId = ""; model.connections[0].mockScenario = "Complete"; - expect(validateConnection(model.connections[0])).toEqual({}); + expect(validateConnection(model.connections[0], providerDescriptor(model, model.connections[0].provider))).toEqual({}); expect(createSettingsUpdate(model).connections[0].mockScenario).toBe("Complete"); }); @@ -63,8 +87,8 @@ describe("analytics settings model", () => { const model = settings(); model.connections[0].projectId = ""; - expect(validateConnection(model.connections[0])).toEqual({ - projectId: "Enter the Vercel project ID.", + expect(validateConnection(model.connections[0], providerDescriptor(model, model.connections[0].provider))).toEqual({ + projectId: "Enter a Vercel project ID.", }); }); @@ -94,4 +118,19 @@ describe("analytics settings model", () => { expect(createSettingsUpdate(model).connections[0].key).toBe(model.connections[0].key); }); + + it("validates and serializes Plausible event property names", () => { + const model = settings(); + const connection = model.connections[0]; + connection.provider = "Plausible"; + connection.projectId = ""; + connection.siteId = "example.com"; + connection.eventPropertyNames = ["locale", "title"]; + + expect(validateConnection(connection, providerDescriptor(model, connection.provider))).toEqual({}); + expect(createSettingsUpdate(model).connections[0].eventPropertyNames).toEqual(["locale", "title"]); + + connection.eventPropertyNames = Array.from({ length: 21 }, (_, index) => `property-${index}`); + expect(validateConnection(connection, providerDescriptor(model, connection.provider)).eventPropertyNames).toBe("Add no more than 20 event properties."); + }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts index f5d6546..9b08084 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts @@ -1,10 +1,12 @@ import type { AnalyticsConnectionSettingsResponse, + AnalyticsProviderDescriptor, AnalyticsSettingsResponse, UpdateAnalyticsSettingsRequest, } from "../api/types.gen.js"; +import { identifierField, identifierValue, providerDescriptor } from "./provider-identity.js"; -export type ConnectionValidationErrors = Partial>; +export type ConnectionValidationErrors = Partial>; export function teamReference(connection: AnalyticsConnectionSettingsResponse): string { return connection.team?.trim() || ""; @@ -14,17 +16,32 @@ export function parseTeamReference(value: string): Pick eventProperties.maximumNames) + errors.eventPropertyNames = `Add no more than ${eventProperties.maximumNames} event properties.`; + else if (eventProperties && connection.eventPropertyNames.some((name) => name.length > eventProperties.maximumNameLength)) + errors.eventPropertyNames = `Event property names must be ${eventProperties.maximumNameLength} characters or fewer.`; return errors; } export function validateEditableSettings(settings: AnalyticsSettingsResponse): string | undefined { for (const connection of settings.connections) { - const errors = validateConnection(connection); + const errors = validateConnection(connection, providerDescriptor(settings, connection.provider)); + if (errors.provider) return errors.provider; if (errors.projectId) return `Complete the required fields for “${connection.displayName || connection.projectId || "New connection"}”.`; + if (errors.siteId) return `Complete the required fields for “${connection.displayName || connection.siteId || "New connection"}”.`; if (errors.team) return `Fix the team ownership for “${connection.displayName || connection.projectId || "New connection"}”.`; + if (errors.eventPropertyNames) return `Fix the event properties for “${connection.displayName || connection.siteId || "New connection"}”.`; } return undefined; } @@ -37,8 +54,11 @@ export function createSettingsUpdate(settings: AnalyticsSettingsResponse): Updat connections: settings.connections.map((connection) => ({ key: connection.key, displayName: connection.displayName, + provider: connection.provider, projectId: connection.projectId, team: connection.team, + siteId: connection.siteId, + eventPropertyNames: connection.eventPropertyNames, mockScenario: connection.mockScenario, documentRootKeys: connection.documentRootKeys, enableAllDocumentTypes: connection.enableAllDocumentTypes, diff --git a/src/TheBuilder.WebAnalytics/Client/src/workspace/analytics-workspace.element.ts b/src/TheBuilder.WebAnalytics/Client/src/workspace/analytics-workspace.element.ts index 0c2fa25..42c22f3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/workspace/analytics-workspace.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/workspace/analytics-workspace.element.ts @@ -5,8 +5,8 @@ import { UMB_APP_LANGUAGE_CONTEXT } from "@umbraco-cms/backoffice/language"; import { workspaceAnalyticsCulture } from "../analytics/document-route.js"; import "../analytics/analytics-dashboard.element.js"; -@customElement("vercel-analytics-workspace") -export class VercelAnalyticsWorkspaceElement extends UmbElementMixin(LitElement) { +@customElement("web-analytics-workspace") +export class WebAnalyticsWorkspaceElement extends UmbElementMixin(LitElement) { @state() private _documentId?: string; @state() private _variantCulture?: string; @state() private _appCulture?: string; @@ -15,27 +15,27 @@ export class VercelAnalyticsWorkspaceElement extends UmbElementMixin(LitElement) super(); this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => { if (!context) return; - this.observe(context.unique, (unique) => (this._documentId = unique ?? undefined), "vercelAnalyticsDocumentUnique"); - this.observe(context.splitView.firstActiveVariantInfo, (variant) => (this._variantCulture = variant?.culture ?? undefined), "vercelAnalyticsCulture"); + this.observe(context.unique, (unique) => (this._documentId = unique ?? undefined), "webAnalyticsDocumentUnique"); + this.observe(context.splitView.firstActiveVariantInfo, (variant) => (this._variantCulture = variant?.culture ?? undefined), "webAnalyticsCulture"); }); this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (context) => { if (!context) return; - this.observe(context.appLanguageCulture, (culture) => (this._appCulture = culture ?? undefined), "vercelAnalyticsAppCulture"); + this.observe(context.appLanguageCulture, (culture) => (this._appCulture = culture ?? undefined), "webAnalyticsAppCulture"); }); } render() { const culture = workspaceAnalyticsCulture(this._variantCulture, this._appCulture); return this._documentId - ? html`` + ? html`` : html``; } } -export default VercelAnalyticsWorkspaceElement; +export default WebAnalyticsWorkspaceElement; declare global { interface HTMLElementTagNameMap { - "vercel-analytics-workspace": VercelAnalyticsWorkspaceElement; + "web-analytics-workspace": WebAnalyticsWorkspaceElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/workspace/document-analytics.condition.ts b/src/TheBuilder.WebAnalytics/Client/src/workspace/document-analytics.condition.ts index 588f117..c0312d3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/workspace/document-analytics.condition.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/workspace/document-analytics.condition.ts @@ -11,8 +11,8 @@ export class DocumentAnalyticsCondition extends UmbConditionBase { if (!context) return; - this.observe(context.unique, (unique) => void this.#evaluate(unique ?? undefined), "vercelAnalyticsConditionUnique"); - this.observe(context.splitView.firstActiveVariantInfo, () => void this.#evaluate(context.getUnique() ?? undefined), "vercelAnalyticsConditionCulture"); + this.observe(context.unique, (unique) => void this.#evaluate(unique ?? undefined), "webAnalyticsConditionUnique"); + this.observe(context.splitView.firstActiveVariantInfo, () => void this.#evaluate(context.getUnique() ?? undefined), "webAnalyticsConditionCulture"); }); } diff --git a/src/TheBuilder.WebAnalytics/Composers/WebAnalyticsApiComposer.cs b/src/TheBuilder.WebAnalytics/Composers/WebAnalyticsApiComposer.cs index b123bc9..af24b47 100644 --- a/src/TheBuilder.WebAnalytics/Composers/WebAnalyticsApiComposer.cs +++ b/src/TheBuilder.WebAnalytics/Composers/WebAnalyticsApiComposer.cs @@ -13,24 +13,21 @@ public class WebAnalyticsApiComposer : IComposer public void Compose(IUmbracoBuilder builder) { builder.Services - .AddOptions() - .Bind(builder.Config.GetSection(VercelAnalyticsOptions.SectionName)) + .AddOptions() + .Bind(builder.Config.GetSection(WebAnalyticsOptions.SectionName)) .ValidateOnStart(); - builder.Services.AddSingleton, VercelAnalyticsOptionsValidator>(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton, WebAnalyticsOptionsValidator>(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://api.vercel.com/"); - client.Timeout = TimeSpan.FromSeconds(15); - }); - builder.Services.AddSingleton(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddSingleton(); + foreach (var provider in AnalyticsProviderCatalog.Default.Registrations) + provider.RegisterClient(builder.Services); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsConnectionRegistry.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs similarity index 64% rename from src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsConnectionRegistry.cs rename to src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs index 8e02ae2..049052a 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsConnectionRegistry.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs @@ -3,29 +3,24 @@ namespace TheBuilder.WebAnalytics.Configuration; -public sealed class VercelAnalyticsConnectionRegistry +public sealed class AnalyticsConnectionRegistry { - private readonly VercelAnalyticsSettingsStore _settingsStore; - private readonly IOptions _serverOptions; + private readonly WebAnalyticsSettingsStore _settingsStore; + private readonly IOptions _serverOptions; private readonly bool _mockConnectionsEnabled; private readonly Lock _snapshotLock = new(); private RegistrySnapshot? _snapshot; - public VercelAnalyticsConnectionRegistry( - VercelAnalyticsSettingsStore settingsStore, - IOptions serverOptions) + public AnalyticsConnectionRegistry( + WebAnalyticsSettingsStore settingsStore, + IOptions serverOptions) : this(settingsStore, serverOptions, serverOptions.Value.EnableMockConnections) { } - public VercelAnalyticsConnectionRegistry(IOptions serverOptions) - : this(new VercelAnalyticsSettingsStore(serverOptions), serverOptions, false) - { - } - - internal VercelAnalyticsConnectionRegistry( - VercelAnalyticsSettingsStore settingsStore, - IOptions serverOptions, + internal AnalyticsConnectionRegistry( + WebAnalyticsSettingsStore settingsStore, + IOptions serverOptions, bool mockConnectionsEnabled) { _settingsStore = settingsStore; @@ -35,14 +30,14 @@ internal VercelAnalyticsConnectionRegistry( public bool MockConnectionsEnabled => _mockConnectionsEnabled; - public VercelAnalyticsSettings Settings => Capture().Settings; + public WebAnalyticsSettings Settings => Capture().Settings; - public IEnumerable Connections => Capture().Connections.Values; + public IEnumerable Connections => Capture().Connections.Values; - public VercelAnalyticsConnection? Get(Guid key) => + public AnalyticsConnection? Get(Guid key) => Capture().Get(key); - public VercelAnalyticsConnection? FindNearestRoot(IEnumerable ancestorKeys) + public AnalyticsConnection? FindNearestRoot(IEnumerable ancestorKeys) { var snapshot = Capture(); foreach (var key in ancestorKeys) @@ -71,18 +66,17 @@ internal RegistrySnapshot Capture() } } - private RegistrySnapshot CreateSnapshot(VercelAnalyticsSettingsSnapshot settingsSnapshot) + private RegistrySnapshot CreateSnapshot(WebAnalyticsSettingsSnapshot settingsSnapshot) { var serverConfiguration = _serverOptions.Value; - var vercelConfiguration = serverConfiguration.Providers.Vercel; var connections = settingsSnapshot.Settings.Connections .Where(connection => !connection.IsMock || _mockConnectionsEnabled) .ToDictionary( connection => connection.Key, - connection => VercelAnalyticsConnection.Create( + connection => AnalyticsConnection.Create( connection, ResolveAccessToken( - vercelConfiguration.AccessToken, + AnalyticsProviderCatalog.Default.Get(connection.Provider).GetAccessToken(serverConfiguration), serverConfiguration.ConnectionAccessTokens.GetValueOrDefault(connection.Key.ToString())))); var roots = connections.Values .SelectMany(connection => connection.DocumentRootKeys.Select(rootKey => (rootKey, connection.Key))) @@ -100,21 +94,24 @@ private static string ResolveAccessToken(string? sharedAccessToken, string? conn : sharedAccessToken ?? string.Empty; internal sealed record RegistrySnapshot( - VercelAnalyticsSettings Settings, + WebAnalyticsSettings Settings, long Revision, - IReadOnlyDictionary Connections, + IReadOnlyDictionary Connections, IReadOnlyDictionary RootOwners) { - public VercelAnalyticsConnection? Get(Guid key) => Connections.GetValueOrDefault(key); + public AnalyticsConnection? Get(Guid key) => Connections.GetValueOrDefault(key); } } -public sealed record VercelAnalyticsConnection( +public sealed record AnalyticsConnection( Guid Key, string DisplayName, + AnalyticsProvider Provider, string AccessToken, string ProjectId, string? Team, + string SiteId, + IReadOnlyList EventPropertyNames, IReadOnlyList DocumentRootKeys, bool EnableAllDocumentTypes, IReadOnlySet EnabledDocumentTypeKeys, @@ -125,22 +122,32 @@ public sealed record VercelAnalyticsConnection( public bool IsMock => MockScenario is not null; - public bool IsConfigured => IsMock || HasAccessToken && !string.IsNullOrWhiteSpace(ProjectId); + public bool IsConfigured => IsMock || HasAccessToken && + !string.IsNullOrWhiteSpace(AnalyticsProviderCatalog.Default.Get(Provider).GetIdentifier(this)); + + public AnalyticsCapabilities Capabilities => IsMock + ? AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel).Capabilities + : AnalyticsProviderCatalog.Default.Get(Provider).Capabilities; public override string ToString() => - $"{nameof(VercelAnalyticsConnection)} {{ Key = {Key}, DisplayName = {DisplayName}, ProjectId = {ProjectId}, Team = {Team}, AccessToken = [REDACTED] }}"; + $"{nameof(AnalyticsConnection)} {{ Key = {Key}, DisplayName = {DisplayName}, Provider = {Provider}, ProjectId = {ProjectId}, Team = {Team}, SiteId = {SiteId}, AccessToken = [REDACTED] }}"; public bool IsDocumentTypeEnabled(string documentTypeAlias, Guid documentTypeKey) => EnableAllDocumentTypes || EnabledDocumentTypeKeys.Contains(documentTypeKey) || EnabledDocumentTypes.Contains(documentTypeAlias); - internal static VercelAnalyticsConnection Create( - VercelAnalyticsConnectionSettings settings, + internal static AnalyticsConnection Create( + AnalyticsConnectionSettings settings, string? accessToken) => new( settings.Key, - string.IsNullOrWhiteSpace(settings.DisplayName) ? settings.ProjectId : settings.DisplayName, + string.IsNullOrWhiteSpace(settings.DisplayName) + ? AnalyticsProviderCatalog.Default.Get(settings.Provider).GetIdentifier(settings) + : settings.DisplayName, + settings.Provider, accessToken ?? string.Empty, settings.ProjectId, NullIfWhiteSpace(settings.Team), + settings.SiteId, + settings.EventPropertyNames, ParseGuidValues(settings.DocumentRootKeys), settings.EnableAllDocumentTypes, ParseGuidValues(settings.EnabledDocumentTypeKeys).ToHashSet(), diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs new file mode 100644 index 0000000..7ee390c --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs @@ -0,0 +1,33 @@ +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Configuration; + +internal static class AnalyticsProviderCapabilities +{ + internal static AnalyticsCapabilities FromClient( + IReadOnlyList dimensions, + bool globalEventFiltering, + bool breakdownOrdering) + where TClient : class, IAnalyticsProviderClient + { + var clientType = typeof(TClient); + var events = typeof(IAnalyticsEventsProviderClient).IsAssignableFrom(clientType); + var eventDetails = typeof(IAnalyticsEventDetailsProviderClient).IsAssignableFrom(clientType); + var eventProperties = typeof(IAnalyticsEventPropertiesProviderClient).IsAssignableFrom(clientType); + var flags = typeof(IAnalyticsFlagsProviderClient).IsAssignableFrom(clientType); + + if (globalEventFiltering && !events) + throw new InvalidOperationException( + $"{clientType.Name} cannot enable global event filtering without implementing {nameof(IAnalyticsEventsProviderClient)}."); + + return new( + dimensions, + events, + eventDetails, + eventProperties, + globalEventFiltering, + flags, + breakdownOrdering); + } +} diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs new file mode 100644 index 0000000..2cf52f7 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs @@ -0,0 +1,224 @@ +using System.Net; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Providers; + +namespace TheBuilder.WebAnalytics.Configuration; + +public sealed class AnalyticsProviderCatalog +{ + public static AnalyticsProviderCatalog Default { get; } = new(); + + private readonly IReadOnlyDictionary _definitions; + + private AnalyticsProviderCatalog() + { + Registrations = + [ + VercelProvider.Registration, + PlausibleProvider.Registration + ]; + Definitions = Registrations.Select(registration => registration.Definition).ToArray(); + _definitions = Definitions.ToDictionary(definition => definition.Provider); + } + + internal IReadOnlyList Registrations { get; } + + public IReadOnlyList Definitions { get; } + + public AnalyticsProviderDefinition Get(AnalyticsProvider provider) => + _definitions.TryGetValue(provider, out var definition) + ? definition + : throw new ArgumentOutOfRangeException(nameof(provider), provider, "Unsupported analytics provider."); + + public bool TryGet(AnalyticsProvider provider, out AnalyticsProviderDefinition definition) => + _definitions.TryGetValue(provider, out definition!); +} + +public sealed class AnalyticsProviderDefinition( + AnalyticsProvider provider, + AnalyticsCapabilities capabilities, + AnalyticsIdentifierDefinition identifier, + AnalyticsProviderSettingsDescriptor settings, + Func accessToken, + IReadOnlySet? invalidQueryStatuses = null, + Func? fallbackBaseUrl = null) +{ + private readonly IReadOnlySet _invalidQueryStatuses = + invalidQueryStatuses ?? new HashSet { HttpStatusCode.BadRequest }; + + public AnalyticsProvider Provider { get; } = provider; + + public AnalyticsCapabilities Capabilities { get; } = capabilities; + + public AnalyticsIdentifierDefinition Identifier { get; } = identifier; + + public bool SupportsTeam => Settings.Team is not null; + + public AnalyticsProviderSettingsDescriptor Settings { get; } = settings; + + public string GetAccessToken(WebAnalyticsOptions options) => accessToken(options); + + public AnalyticsProviderDescriptor ToDescriptor() => new( + Provider, + Settings.Description, + Settings.LogoSlug, + Identifier.ToDescriptor(), + Settings.Team, + Settings.Credential, + Settings.EventProperties); + + public string GetIdentifier(AnalyticsConnectionSettings connection) => Identifier.GetValue(connection); + + public string GetIdentifier(AnalyticsConnection connection) => Identifier.GetValue(connection); + + public string? GetFallbackBaseUrl(AnalyticsConnection connection) => + fallbackBaseUrl?.Invoke(connection); + + public AnalyticsProviderFields Normalize(AnalyticsConnectionSettings connection) => new( + !connection.IsMock && Identifier.Field == AnalyticsConnectionIdentifier.ProjectId ? connection.ProjectId.Trim() : string.Empty, + !connection.IsMock && Settings.Team is not null ? NullIfWhiteSpace(connection.Team) : null, + !connection.IsMock && Identifier.Field == AnalyticsConnectionIdentifier.SiteId ? connection.SiteId.Trim() : string.Empty, + !connection.IsMock && Settings.EventProperties is not null + ? NormalizeNames(connection.EventPropertyNames ?? []) + : []); + + public void ValidateConnection( + AnalyticsConnectionSettings connection, + string label, + bool requireConnectionMetadata, + bool isSupportedMockScenario, + ICollection failures) + { + if (requireConnectionMetadata && !isSupportedMockScenario && string.IsNullOrWhiteSpace(GetIdentifier(connection))) + failures.Add($"Connection '{label}' requires {Identifier.RequiredMessage}."); + + if (!string.IsNullOrWhiteSpace(connection.ProjectId) && + (isSupportedMockScenario || Identifier.Field != AnalyticsConnectionIdentifier.ProjectId)) + failures.Add($"Connection '{label}' cannot define a {AnalyticsConnectionIdentifier.ProjectId.ToLabel()}."); + if (!string.IsNullOrWhiteSpace(connection.Team) && (isSupportedMockScenario || !SupportsTeam)) + failures.Add($"Connection '{label}' cannot define a Vercel team."); + if (!string.IsNullOrWhiteSpace(connection.SiteId) && + (isSupportedMockScenario || Identifier.Field != AnalyticsConnectionIdentifier.SiteId)) + failures.Add($"Connection '{label}' cannot define a {AnalyticsConnectionIdentifier.SiteId.ToLabel()}."); + + var eventPropertyNames = connection.EventPropertyNames ?? []; + if (Settings.EventProperties is { } eventProperties) + { + if (eventPropertyNames.Count(value => !string.IsNullOrWhiteSpace(value)) > eventProperties.MaximumNames) + failures.Add($"Connection '{label}' cannot define more than {eventProperties.MaximumNames} event properties."); + if (eventPropertyNames.Any(value => value is null || value.Trim().Length > eventProperties.MaximumNameLength || value.Any(char.IsControl))) + failures.Add($"Connection '{label}' contains an invalid event property name."); + } + + if (eventPropertyNames.Any(value => !string.IsNullOrWhiteSpace(value)) && + (Settings.EventProperties is null || isSupportedMockScenario)) + failures.Add($"Connection '{label}' cannot define {Settings.EventProperties?.Label ?? "event properties"} for {Provider}."); + } + + public bool IsInvalidQuery(HttpStatusCode statusCode) => _invalidQueryStatuses.Contains(statusCode); + + public string ConnectionTestFailure(HttpStatusCode statusCode) => statusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => + $"{Provider} rejected the configured credentials or connection access.", + HttpStatusCode.PaymentRequired => + $"The analytics API is unavailable for the current {Provider} plan or reporting window.", + HttpStatusCode.TooManyRequests => + $"{Provider} rate-limited the connection test. Try again shortly.", + _ when IsInvalidQuery(statusCode) => + $"{Provider} rejected the connection identifier or analytics query.", + _ => $"{Provider} Analytics is temporarily unavailable." + }; + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string[] NormalizeNames(IEnumerable values) => values + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} + +public sealed record AnalyticsProviderSettingsDescriptor( + string Description, + string LogoSlug, + AnalyticsOptionalFieldDescriptor? Team, + AnalyticsCredentialDescriptor Credential, + AnalyticsEventPropertyDescriptor? EventProperties); + +public sealed record AnalyticsIdentifierDefinition( + AnalyticsConnectionIdentifier Field, + string Description, + string RequiredMessage) +{ + public string Label => Field.ToLabel(); + + internal AnalyticsIdentifierFieldDescriptor ToDescriptor() => + new(Field.ToFieldName(), Label, Description, RequiredMessage); + + internal string GetValue(AnalyticsConnectionSettings connection) => Field switch + { + AnalyticsConnectionIdentifier.ProjectId => connection.ProjectId, + AnalyticsConnectionIdentifier.SiteId => connection.SiteId, + _ => throw new ArgumentOutOfRangeException(nameof(Field), Field, "Unsupported analytics connection identifier.") + }; + + internal string GetValue(AnalyticsConnection connection) => Field switch + { + AnalyticsConnectionIdentifier.ProjectId => connection.ProjectId, + AnalyticsConnectionIdentifier.SiteId => connection.SiteId, + _ => throw new ArgumentOutOfRangeException(nameof(Field), Field, "Unsupported analytics connection identifier.") + }; +} + +public sealed record AnalyticsIdentifierFieldDescriptor( + string Key, + string Label, + string Description, + string RequiredMessage); + +public sealed record AnalyticsCredentialDescriptor( + string Label, + string Description, + string DocumentationUrl); + +public sealed record AnalyticsOptionalFieldDescriptor( + string Key, + string Label, + string Description); + +public sealed record AnalyticsEventPropertyDescriptor( + string Label, + string Description, + int MaximumNames, + int MaximumNameLength); + +public sealed record AnalyticsProviderFields( + string ProjectId, + string? Team, + string SiteId, + string[] EventPropertyNames); + +public enum AnalyticsConnectionIdentifier +{ + ProjectId, + SiteId +} + +internal static class AnalyticsConnectionIdentifierExtensions +{ + internal static string ToFieldName(this AnalyticsConnectionIdentifier identifier) => identifier switch + { + AnalyticsConnectionIdentifier.ProjectId => "projectId", + AnalyticsConnectionIdentifier.SiteId => "siteId", + _ => throw new ArgumentOutOfRangeException(nameof(identifier), identifier, "Unsupported analytics connection identifier.") + }; + + internal static string ToLabel(this AnalyticsConnectionIdentifier identifier) => identifier switch + { + AnalyticsConnectionIdentifier.ProjectId => "Vercel project ID", + AnalyticsConnectionIdentifier.SiteId => "Plausible site ID", + _ => throw new ArgumentOutOfRangeException(nameof(identifier), identifier, "Unsupported analytics connection identifier.") + }; +} diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs new file mode 100644 index 0000000..3ac7221 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Configuration; + +internal sealed class AnalyticsProviderRegistration( + AnalyticsProviderDefinition definition, + Action registerClient) +{ + internal AnalyticsProviderDefinition Definition { get; } = definition; + + internal void RegisterClient(IServiceCollection services) => registerClient(services); + + internal static AnalyticsProviderRegistration Create( + AnalyticsProviderDefinition definition, + Uri baseAddress) + where TClient : class, IAnalyticsProviderClient => + new(definition, services => services.AddAnalyticsProvider(baseAddress)); +} diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderServiceCollectionExtensions.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderServiceCollectionExtensions.cs new file mode 100644 index 0000000..1012f68 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Configuration; + +internal static class AnalyticsProviderServiceCollectionExtensions +{ + internal static IServiceCollection AddAnalyticsProvider( + this IServiceCollection services, + Uri baseAddress) + where TClient : class, IAnalyticsProviderClient + { + services.AddHttpClient(client => + { + client.BaseAddress = baseAddress; + client.Timeout = TimeSpan.FromSeconds(15); + }); + services.AddTransient(serviceProvider => + serviceProvider.GetRequiredService()); + return services; + } +} diff --git a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptionsValidator.cs b/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptionsValidator.cs deleted file mode 100644 index b81495b..0000000 --- a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptionsValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace TheBuilder.WebAnalytics.Configuration; - -public sealed class VercelAnalyticsOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, VercelAnalyticsOptions options) - { - var settings = VercelAnalyticsSettingsMapper.FromServerOptions(options); - var failures = VercelAnalyticsSettingsValidator.Validate( - settings, - VercelAnalyticsValidationMode.ServerOptions); - - return failures.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(failures); - } -} diff --git a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptions.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs similarity index 70% rename from src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptions.cs rename to src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs index 85387e3..a41f3ff 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsOptions.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs @@ -2,7 +2,7 @@ namespace TheBuilder.WebAnalytics.Configuration; -public sealed class VercelAnalyticsOptions +public sealed class WebAnalyticsOptions { public const string SectionName = "WebAnalytics"; @@ -14,7 +14,7 @@ public sealed class VercelAnalyticsOptions public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5); - public List Connections { get; set; } = []; + public List Connections { get; set; } = []; public Dictionary ConnectionAccessTokens { get; set; } = new(StringComparer.OrdinalIgnoreCase); @@ -25,6 +25,8 @@ public sealed class VercelAnalyticsOptions public sealed class WebAnalyticsProvidersOptions { public VercelAnalyticsProviderOptions Vercel { get; set; } = new(); + + public PlausibleAnalyticsProviderOptions Plausible { get; set; } = new(); } public sealed class VercelAnalyticsProviderOptions @@ -32,16 +34,27 @@ public sealed class VercelAnalyticsProviderOptions public string AccessToken { get; set; } = string.Empty; } -public sealed class VercelAnalyticsConnectionOptions +public sealed class PlausibleAnalyticsProviderOptions +{ + public string AccessToken { get; set; } = string.Empty; +} + +public sealed class AnalyticsConnectionOptions { public Guid Key { get; set; } public string DisplayName { get; set; } = string.Empty; + public AnalyticsProvider Provider { get; set; } = AnalyticsProvider.Vercel; + public string ProjectId { get; set; } = string.Empty; public string? Team { get; set; } + public string SiteId { get; set; } = string.Empty; + + public string[] EventPropertyNames { get; set; } = []; + public MockAnalyticsScenario? MockScenario { get; set; } public string[] DocumentRootKeys { get; set; } = []; diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs new file mode 100644 index 0000000..81a71e0 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Options; + +namespace TheBuilder.WebAnalytics.Configuration; + +public sealed class WebAnalyticsOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, WebAnalyticsOptions options) + { + var settings = WebAnalyticsSettingsMapper.FromServerOptions(options); + var failures = WebAnalyticsSettingsValidator.Validate( + settings, + WebAnalyticsValidationMode.ServerOptions); + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } +} diff --git a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettings.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs similarity index 66% rename from src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettings.cs rename to src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs index e008e48..fcdf443 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettings.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs @@ -8,7 +8,7 @@ namespace TheBuilder.WebAnalytics.Configuration; -public sealed class VercelAnalyticsSettings +public sealed class WebAnalyticsSettings { public bool Enabled { get; set; } = true; @@ -16,19 +16,25 @@ public sealed class VercelAnalyticsSettings public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5); - public List Connections { get; set; } = []; + public List Connections { get; set; } = []; } -public sealed class VercelAnalyticsConnectionSettings +public sealed class AnalyticsConnectionSettings { public Guid Key { get; set; } public string DisplayName { get; set; } = string.Empty; + public AnalyticsProvider Provider { get; set; } = AnalyticsProvider.Vercel; + public string ProjectId { get; set; } = string.Empty; public string? Team { get; set; } + public string SiteId { get; set; } = string.Empty; + + public string[] EventPropertyNames { get; set; } = []; + public MockAnalyticsScenario? MockScenario { get; set; } [JsonIgnore] @@ -44,34 +50,34 @@ public sealed class VercelAnalyticsConnectionSettings public string[] EnabledDocumentTypes { get; set; } = []; } -public sealed class VercelAnalyticsSettingsStore +public sealed class WebAnalyticsSettingsStore { private const string StorageKey = "TheBuilder.WebAnalytics.Settings.v2"; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private readonly Lock _lock = new(); private readonly IKeyValueService? _keyValueService; - private readonly Microsoft.Extensions.Options.IOptions _serverOptions; - private VercelAnalyticsSettings? _cached; + private readonly Microsoft.Extensions.Options.IOptions _serverOptions; + private WebAnalyticsSettings? _cached; private string? _cachedJson; private long _revision; - public VercelAnalyticsSettingsStore( + public WebAnalyticsSettingsStore( IKeyValueService keyValueService, - Microsoft.Extensions.Options.IOptions serverOptions) + Microsoft.Extensions.Options.IOptions serverOptions) { _keyValueService = keyValueService; _serverOptions = serverOptions; } - public VercelAnalyticsSettingsStore( - Microsoft.Extensions.Options.IOptions serverOptions) + public WebAnalyticsSettingsStore( + Microsoft.Extensions.Options.IOptions serverOptions) { _serverOptions = serverOptions; } - public VercelAnalyticsSettings Get() => GetSnapshot().Settings; + public WebAnalyticsSettings Get() => GetSnapshot().Settings; - internal VercelAnalyticsSettingsSnapshot GetSnapshot() + internal WebAnalyticsSettingsSnapshot GetSnapshot() { lock (_lock) { @@ -79,20 +85,20 @@ internal VercelAnalyticsSettingsSnapshot GetSnapshot() if (_cached is null || (_keyValueService is not null && !string.Equals(json, _cachedJson, StringComparison.Ordinal))) { var settings = string.IsNullOrWhiteSpace(json) - ? VercelAnalyticsSettingsMapper.FromServerOptions(_serverOptions.Value) - : JsonSerializer.Deserialize(json, SerializerOptions) - ?? VercelAnalyticsSettingsMapper.FromServerOptions(_serverOptions.Value); + ? WebAnalyticsSettingsMapper.FromServerOptions(_serverOptions.Value) + : JsonSerializer.Deserialize(json, SerializerOptions) + ?? WebAnalyticsSettingsMapper.FromServerOptions(_serverOptions.Value); _cached = Normalize(settings); _cachedJson = JsonSerializer.Serialize(_cached, SerializerOptions); if (string.IsNullOrWhiteSpace(json)) _keyValueService?.SetValue(StorageKey, _cachedJson); _revision = ComputeRevision(_cachedJson); } - return new VercelAnalyticsSettingsSnapshot(_cached, _revision); + return new WebAnalyticsSettingsSnapshot(_cached, _revision); } } - public void Save(VercelAnalyticsSettings settings) + public void Save(WebAnalyticsSettings settings) { var normalized = Normalize(settings); var json = JsonSerializer.Serialize(normalized, SerializerOptions); @@ -105,19 +111,29 @@ public void Save(VercelAnalyticsSettings settings) } } - private static VercelAnalyticsSettings Normalize(VercelAnalyticsSettings settings) => new() + private static WebAnalyticsSettings Normalize(WebAnalyticsSettings settings) => new() { Enabled = settings.Enabled, DefaultRangeDays = settings.DefaultRangeDays, CacheDuration = settings.CacheDuration, - Connections = settings.Connections.Select(connection => new VercelAnalyticsConnectionSettings + Connections = settings.Connections.Select(NormalizeConnection).ToList() + }; + + private static AnalyticsConnectionSettings NormalizeConnection(AnalyticsConnectionSettings connection) + { + var definition = AnalyticsProviderCatalog.Default.Get(connection.Provider); + var fields = definition.Normalize(connection); + return new AnalyticsConnectionSettings { Key = connection.Key == Guid.Empty ? Guid.NewGuid() : connection.Key, + Provider = connection.Provider, DisplayName = connection.MockScenario is { } scenario ? MockAnalyticsScenarioMetadata.DisplayName(scenario) : connection.DisplayName.Trim(), - ProjectId = connection.IsMock ? string.Empty : connection.ProjectId.Trim(), - Team = connection.IsMock ? null : NullIfWhiteSpace(connection.Team), + ProjectId = fields.ProjectId, + Team = fields.Team, + SiteId = fields.SiteId, + EventPropertyNames = fields.EventPropertyNames, MockScenario = connection.MockScenario, DocumentRootKeys = NormalizeGuidValues(connection.DocumentRootKeys), EnableAllDocumentTypes = connection.EnableAllDocumentTypes, @@ -127,11 +143,8 @@ public void Save(VercelAnalyticsSettings settings) .Where(value => value.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray() - }).ToList() - }; - - private static string? NullIfWhiteSpace(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + }; + } private static long ComputeRevision(string json) => BinaryPrimitives.ReadInt64LittleEndian(SHA256.HashData(Encoding.UTF8.GetBytes(json))) & long.MaxValue; @@ -141,29 +154,33 @@ private static string[] NormalizeGuidValues(IEnumerable values) => value .OfType() .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + } -internal sealed record VercelAnalyticsSettingsSnapshot( - VercelAnalyticsSettings Settings, +internal sealed record WebAnalyticsSettingsSnapshot( + WebAnalyticsSettings Settings, long Revision); -internal static class VercelAnalyticsSettingsMapper +internal static class WebAnalyticsSettingsMapper { - public static VercelAnalyticsSettings FromServerOptions(VercelAnalyticsOptions options) + public static WebAnalyticsSettings FromServerOptions(WebAnalyticsOptions options) { - var connections = options.Connections.Select(connection => new VercelAnalyticsConnectionSettings + var connections = options.Connections.Select(connection => new AnalyticsConnectionSettings { Key = connection.Key == Guid.Empty ? Guid.NewGuid() : connection.Key, + Provider = connection.Provider, DisplayName = connection.DisplayName, ProjectId = connection.ProjectId, Team = connection.Team, + SiteId = connection.SiteId, + EventPropertyNames = connection.EventPropertyNames, MockScenario = connection.MockScenario, DocumentRootKeys = connection.DocumentRootKeys, EnableAllDocumentTypes = connection.EnableAllDocumentTypes, EnabledDocumentTypeKeys = connection.EnabledDocumentTypeKeys, EnabledDocumentTypes = connection.EnabledDocumentTypes }).ToList(); - return new VercelAnalyticsSettings + return new WebAnalyticsSettings { Enabled = options.Enabled, DefaultRangeDays = options.DefaultRangeDays, diff --git a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettingsValidator.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs similarity index 65% rename from src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettingsValidator.cs rename to src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs index 080b7bd..c7b2e07 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/VercelAnalyticsSettingsValidator.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettingsValidator.cs @@ -1,15 +1,17 @@ +using TheBuilder.WebAnalytics.Models; + namespace TheBuilder.WebAnalytics.Configuration; -public static class VercelAnalyticsSettingsValidator +public static class WebAnalyticsSettingsValidator { - public static IReadOnlyList Validate(VercelAnalyticsSettings settings) => - Validate(settings, VercelAnalyticsValidationMode.PersistedSettings); + public static IReadOnlyList Validate(WebAnalyticsSettings settings) => + Validate(settings, WebAnalyticsValidationMode.PersistedSettings); internal static IReadOnlyList Validate( - VercelAnalyticsSettings settings, - VercelAnalyticsValidationMode mode) + WebAnalyticsSettings settings, + WebAnalyticsValidationMode mode) { - var validateEnabledState = mode == VercelAnalyticsValidationMode.PersistedSettings; + var validateEnabledState = mode == WebAnalyticsValidationMode.PersistedSettings; var failures = new List(); if (settings.DefaultRangeDays is < 1 or > 730) failures.Add("Default range must be between 1 and 730 days."); @@ -31,7 +33,7 @@ internal static IReadOnlyList Validate( } private static void ValidateConnection( - VercelAnalyticsConnectionSettings connection, + AnalyticsConnectionSettings connection, ICollection failures, ISet keys, IDictionary roots, @@ -43,18 +45,16 @@ private static void ValidateConnection( failures.Add($"Connection '{connection.Key}' defines an unsupported mock analytics scenario."); } - var label = connection.MockScenario?.ToString() ?? - (string.IsNullOrWhiteSpace(connection.ProjectId) ? connection.Key.ToString() : connection.ProjectId); + var label = connection.MockScenario?.ToString() ?? FirstNonEmpty(connection.SiteId, connection.ProjectId, connection.Key.ToString()); if (connection.Key == Guid.Empty) failures.Add("Every connection requires a valid key."); else if (!keys.Add(connection.Key)) failures.Add($"Connection key '{connection.Key}' is used more than once."); - if (requireConnectionMetadata && !hasSupportedMockScenario && string.IsNullOrWhiteSpace(connection.ProjectId)) - failures.Add($"Connection '{label}' requires a project ID."); - if (hasSupportedMockScenario && !string.IsNullOrWhiteSpace(connection.ProjectId)) - failures.Add($"Mock connection '{label}' cannot define a Vercel project ID."); - if (hasSupportedMockScenario && !string.IsNullOrWhiteSpace(connection.Team)) - failures.Add($"Mock connection '{label}' cannot define a Vercel team."); + var hasProvider = AnalyticsProviderCatalog.Default.TryGet(connection.Provider, out var provider); + if (!hasProvider) + failures.Add($"Connection '{label}' defines an unsupported analytics provider."); + if (hasProvider) + provider.ValidateConnection(connection, label, requireConnectionMetadata, hasSupportedMockScenario, failures); foreach (var value in connection.DocumentRootKeys) { if (!Guid.TryParse(value, out var key)) @@ -71,9 +71,12 @@ private static void ValidateConnection( failures.Add($"Connection '{label}' contains invalid document type key '{value}'."); } } + + private static string FirstNonEmpty(params string[] values) => + values.First(value => !string.IsNullOrWhiteSpace(value)); } -internal enum VercelAnalyticsValidationMode +internal enum WebAnalyticsValidationMode { PersistedSettings, ServerOptions diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs index 2d550ab..91ef2f8 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs @@ -13,10 +13,10 @@ namespace TheBuilder.WebAnalytics.Controllers; [ApiExplorerSettings(GroupName = "TheBuilder.WebAnalytics")] public sealed class WebAnalyticsApiController( IAnalyticsAuthorizationService authorizationService, - VercelAnalyticsConnectionRegistry registry, - VercelAnalyticsReportService reportService, + AnalyticsConnectionRegistry registry, + AnalyticsReportService reportService, IAnalyticsDocumentRouteService routeService, - IVercelProjectNameService projectNames) : WebAnalyticsApiControllerBase + IAnalyticsConnectionNameService projectNames) : WebAnalyticsApiControllerBase { private enum ReportScope { @@ -25,6 +25,20 @@ private enum ReportScope EventSelection } + private enum ReportCapability + { + Core, + Breakdown, + Events, + EventDetails, + EventProperties, + Flags + } + + private readonly record struct ReportRequirement( + ReportCapability Capability, + AnalyticsDimension? Dimension = null); + [HttpGet("connections")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -35,7 +49,7 @@ public async Task> Connections(Cancel var snapshot = registry.Capture(); var connectionTasks = snapshot.Settings.Connections .Select(settings => snapshot.Get(settings.Key)) - .OfType() + .OfType() .Select((connection, index) => BuildConnectionSummaryAsync(connection, index == 0, cancellationToken)) .ToArray(); var connections = await Task.WhenAll(connectionTasks); @@ -47,11 +61,13 @@ public async Task> Connections(Cancel return Ok(response); async Task BuildConnectionSummaryAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, bool isDefault, CancellationToken token) => new( connection.Key, await projectNames.GetDisplayNameAsync(connection, token), + connection.Provider, + connection.Capabilities, isDefault, connection.IsConfigured, await routeService.GetConnectionBaseUrlAsync(connection, token), @@ -85,7 +101,8 @@ public async Task> Summary( CancellationToken cancellationToken) { var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + new(ReportCapability.Core), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetSummaryAsync(scope.Query!, cancellationToken); return report is null ? NotFoundProblem("The selected analytics connection does not exist.") : Ok(report); @@ -102,6 +119,7 @@ public async Task> Breakdown( [FromQuery] AnalyticsInterval interval, [FromQuery] int limit = 10, [FromQuery] string? search = null, + [FromQuery] AnalyticsTrafficMetric? orderBy = null, [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, @@ -114,10 +132,15 @@ public async Task> Breakdown( } if (limit is < 1 or > 100) return ValidationProblem("Limit must be between 1 and 100."); if (search?.Length > 200) return ValidationProblem("Search must be 200 characters or fewer."); + if (orderBy is not null && !Enum.IsDefined(orderBy.Value)) + return ValidationProblem("The requested breakdown metric is not supported."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + new(ReportCapability.Breakdown, dimension), cancellationToken); if (scope.Error is not null) return scope.Error; - var report = await reportService.GetBreakdownAsync(scope.Query!, dimension, limit, search, cancellationToken); + if (orderBy is not null && registry.Get(connection)?.Capabilities.BreakdownOrdering is not true) + return ValidationProblem("The selected analytics provider does not support selecting a breakdown ordering metric."); + var report = await reportService.GetBreakdownAsync(scope.Query!, dimension, limit, search, cancellationToken, orderBy); return report is null ? NotFoundProblem("The selected analytics connection does not exist.") : Ok(report); } @@ -140,7 +163,8 @@ public async Task> Events( if (limit is < 1 or > 100) return ValidationProblem("Limit must be between 1 and 100."); if (search?.Length > 200) return ValidationProblem("Search must be 200 characters or fewer."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventList, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventList, + new(ReportCapability.Events), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetEventsAsync(scope.Query!, limit, search, cancellationToken); return report is null ? NotFoundProblem("The selected analytics connection does not exist.") : Ok(report); @@ -165,7 +189,8 @@ public async Task> Flags( if (flagKey?.Length > 255) return ValidationProblem("Flag key must be 255 characters or fewer."); if (limit is < 1 or > 100) return ValidationProblem("Limit must be between 1 and 100."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + new(ReportCapability.Flags), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetFlagsAsync(scope.Query!, flagKey, limit, cancellationToken); return report is null ? NotFoundProblem("The selected analytics connection does not exist.") : Ok(report); @@ -202,7 +227,8 @@ public async Task> EventDetails( } var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, + new(string.IsNullOrWhiteSpace(eventProperty) ? ReportCapability.EventDetails : ReportCapability.EventProperties), cancellationToken); if (scope.Error is not null) return scope.Error; var eventDataFilter = string.IsNullOrWhiteSpace(eventProperty) ? null @@ -251,7 +277,8 @@ public async Task> EventPropertyValues( } var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, cancellationToken); + connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, + new(ReportCapability.EventProperties), cancellationToken); if (scope.Error is not null) return scope.Error; var eventDataFilter = string.IsNullOrWhiteSpace(eventProperty) ? null @@ -277,6 +304,7 @@ public async Task> EventPropertyValues( string? path, IReadOnlyList? filterValues, ReportScope reportScope, + ReportRequirement reportRequirement, CancellationToken cancellationToken) { if (!Enum.IsDefined(interval)) @@ -291,24 +319,24 @@ public async Task> EventPropertyValues( { return (null, ValidationProblem(filterError!)); } - if (filters.Any(filter => filter.Dimension == AnalyticsDimension.EventName) && - reportScope != ReportScope.EventList) - { - return (null, ValidationProblem("EventName filters are only supported by the event list report.")); - } if (!registry.Settings.Enabled) { - return (null, VercelAnalyticsProblemFactory.CreateResult( + return (null, WebAnalyticsProblemFactory.CreateResult( StatusCodes.Status503ServiceUnavailable, - VercelAnalyticsProblemCodes.AnalyticsDisabled, - "Vercel Analytics is disabled.")); + WebAnalyticsProblemCodes.AnalyticsDisabled, + "Web Analytics is disabled.")); } if (documentId is null) { - return authorizationService.HasAnalyticsSectionAccess() + if (!authorizationService.HasAnalyticsSectionAccess()) return (null, Forbid()); + var selectedConnection = registry.Get(connection); + if (selectedConnection is null) + return (null, NotFoundProblem("The selected analytics connection does not exist.")); + var capabilityError = ValidateCapabilities(filters, reportScope, reportRequirement, selectedConnection.Capabilities); + return capabilityError is null ? (new AnalyticsQuery(connection, from, to, interval, Filters: filters), null) - : (null, Forbid()); + : (null, capabilityError); } if (!authorizationService.HasContentSectionAccess() || @@ -320,29 +348,61 @@ public async Task> EventPropertyValues( var selectedRoute = routes.FirstOrDefault(route => route.Connection == connection && string.Equals(route.Path, path, StringComparison.Ordinal)); - return selectedRoute is null - ? (null, ValidationProblem("The selected path is not a published route for this document and connection.")) - : (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, filters), null); + if (selectedRoute is null) + return (null, ValidationProblem("The selected path is not a published route for this document and connection.")); + var documentCapabilityError = ValidateCapabilities(filters, reportScope, reportRequirement, selectedRoute.Capabilities); + return documentCapabilityError is null + ? (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, filters), null) + : (null, documentCapabilityError); + } + + private ActionResult? ValidateCapabilities( + IReadOnlyList filters, + ReportScope reportScope, + ReportRequirement requirement, + AnalyticsCapabilities capabilities) + { + var unsupported = filters.FirstOrDefault(filter => !capabilities.Dimensions.Contains(filter.Dimension)); + if (unsupported is not null) + return ValidationProblem($"The selected analytics provider does not support {unsupported.Dimension} filters."); + if (reportScope != ReportScope.EventList && + !capabilities.GlobalEventFiltering && + filters.Any(filter => filter.Dimension == AnalyticsDimension.EventName)) + return ValidationProblem("The selected analytics provider does not support event filters for this report."); + + var supported = requirement.Capability switch + { + ReportCapability.Core => true, + ReportCapability.Breakdown => requirement.Dimension is not null && capabilities.Dimensions.Contains(requirement.Dimension.Value), + ReportCapability.Events => capabilities.Events, + ReportCapability.EventDetails => capabilities.EventDetails, + ReportCapability.EventProperties => capabilities.EventProperties, + ReportCapability.Flags => capabilities.Flags, + _ => false + }; + return supported + ? null + : ValidationProblem($"The selected analytics provider does not support this {requirement.Capability.ToString().ToLowerInvariant()} report."); } private ActionResult ValidationProblem(string detail) => - VercelAnalyticsProblemFactory.CreateResult( + WebAnalyticsProblemFactory.CreateResult( StatusCodes.Status400BadRequest, - VercelAnalyticsProblemCodes.InvalidQuery, + WebAnalyticsProblemCodes.InvalidQuery, "Invalid analytics query.", detail); private ActionResult NotFoundProblem(string detail) => - VercelAnalyticsProblemFactory.CreateResult( + WebAnalyticsProblemFactory.CreateResult( StatusCodes.Status404NotFound, - VercelAnalyticsProblemCodes.ConfigurationNotFound, + WebAnalyticsProblemCodes.ConfigurationNotFound, "Analytics configuration was not found.", detail); - private static IReadOnlyList ConnectionWarnings(VercelAnalyticsConnection connection) + private static IReadOnlyList ConnectionWarnings(AnalyticsConnection connection) { var warnings = new List(); - if (!connection.IsConfigured) warnings.Add("No server-side access token is configured for this connection."); + if (!connection.IsConfigured) warnings.Add("No server-side credential is configured for this connection."); return warnings; } } diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiControllerBase.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiControllerBase.cs index 9f6e34e..154cef3 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiControllerBase.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiControllerBase.cs @@ -7,9 +7,9 @@ namespace TheBuilder.WebAnalytics.Controllers { [ApiController] - [BackOfficeRoute("management/api/v{version:apiVersion}/vercel-analytics")] + [BackOfficeRoute("management/api/v{version:apiVersion}/web-analytics")] [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [TypeFilter(typeof(VercelAnalyticsExceptionFilter))] + [TypeFilter(typeof(WebAnalyticsExceptionFilter))] [MapToApi(Constants.ApiName)] public class WebAnalyticsApiControllerBase : ControllerBase { diff --git a/src/TheBuilder.WebAnalytics/Controllers/VercelAnalyticsExceptionFilter.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsExceptionFilter.cs similarity index 50% rename from src/TheBuilder.WebAnalytics/Controllers/VercelAnalyticsExceptionFilter.cs rename to src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsExceptionFilter.cs index 0b015a6..ee5b764 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/VercelAnalyticsExceptionFilter.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsExceptionFilter.cs @@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using TheBuilder.WebAnalytics.Configuration; using TheBuilder.WebAnalytics.Models; using TheBuilder.WebAnalytics.Services; namespace TheBuilder.WebAnalytics.Controllers; -internal static class VercelAnalyticsProblemCodes +internal static class WebAnalyticsProblemCodes { public const string AnalyticsDisabled = "analytics_disabled"; public const string ConfigurationNotFound = "configuration_not_found"; @@ -22,11 +23,11 @@ internal static class VercelAnalyticsProblemCodes public const string UpstreamUnavailable = "upstream_unavailable"; } -internal sealed record VercelAnalyticsProblemDefinition(int Status, string Code, string Title); +internal sealed record WebAnalyticsProblemDefinition(int Status, string Code, string Title); -internal static class VercelAnalyticsProblemFactory +internal static class WebAnalyticsProblemFactory { - public static ObjectResult CreateResult(VercelAnalyticsProblemDefinition definition, string? detail = null) + public static ObjectResult CreateResult(WebAnalyticsProblemDefinition definition, string? detail = null) { var problem = new AnalyticsProblemDetails { @@ -39,52 +40,60 @@ public static ObjectResult CreateResult(VercelAnalyticsProblemDefinition definit } public static ObjectResult CreateResult(int status, string code, string title, string? detail = null) => - CreateResult(new VercelAnalyticsProblemDefinition(status, code, title), detail); + CreateResult(new WebAnalyticsProblemDefinition(status, code, title), detail); - public static VercelAnalyticsProblemDefinition? FromException(Exception exception) => exception switch + public static WebAnalyticsProblemDefinition? FromException(Exception exception) => exception switch { - VercelAnalyticsApiException apiException => FromVercelStatus(apiException.StatusCode), + AnalyticsProviderApiException apiException => FromProviderStatus(apiException.Provider, apiException.StatusCode), AnalyticsReportCapacityException => new( StatusCodes.Status503ServiceUnavailable, - VercelAnalyticsProblemCodes.ReportCapacity, + WebAnalyticsProblemCodes.ReportCapacity, "The analytics report service is busy. Try again shortly."), TaskCanceledException => new( StatusCodes.Status504GatewayTimeout, - VercelAnalyticsProblemCodes.UpstreamTimeout, - "Vercel Analytics did not respond in time."), + WebAnalyticsProblemCodes.UpstreamTimeout, + "The analytics provider did not respond in time."), HttpRequestException => new( StatusCodes.Status502BadGateway, - VercelAnalyticsProblemCodes.UpstreamTransport, - "Vercel Analytics could not be reached."), + WebAnalyticsProblemCodes.UpstreamTransport, + "The analytics provider could not be reached."), JsonException => new( StatusCodes.Status502BadGateway, - VercelAnalyticsProblemCodes.InvalidUpstreamPayload, - "Vercel Analytics returned an invalid response."), + WebAnalyticsProblemCodes.InvalidUpstreamPayload, + "The analytics provider returned an invalid response."), _ => null }; - private static VercelAnalyticsProblemDefinition FromVercelStatus(HttpStatusCode statusCode) => statusCode switch + private static WebAnalyticsProblemDefinition FromProviderStatus(AnalyticsProvider provider, HttpStatusCode statusCode) { + var definition = AnalyticsProviderCatalog.Default.Get(provider); + return statusCode switch + { HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => new( StatusCodes.Status502BadGateway, - VercelAnalyticsProblemCodes.InvalidCredentials, - "Vercel rejected the configured credentials or project access."), + WebAnalyticsProblemCodes.InvalidCredentials, + $"{provider} rejected the configured credentials or connection access."), HttpStatusCode.PaymentRequired => new( StatusCodes.Status402PaymentRequired, - VercelAnalyticsProblemCodes.PlanLimit, - "The report is unavailable for the current Vercel plan."), - HttpStatusCode.BadRequest => new( + WebAnalyticsProblemCodes.PlanLimit, + $"The report is unavailable for the current {provider} plan."), + _ when definition.IsInvalidQuery(statusCode) => new( StatusCodes.Status400BadRequest, - VercelAnalyticsProblemCodes.InvalidQuery, - "Vercel rejected the analytics query or reporting window."), + WebAnalyticsProblemCodes.InvalidQuery, + $"{provider} rejected the analytics query or reporting window."), + HttpStatusCode.TooManyRequests => new( + StatusCodes.Status503ServiceUnavailable, + WebAnalyticsProblemCodes.UpstreamUnavailable, + $"{provider} rate-limited the analytics request. Try again shortly."), _ => new( StatusCodes.Status502BadGateway, - VercelAnalyticsProblemCodes.UpstreamUnavailable, - "Vercel Analytics is temporarily unavailable.") - }; + WebAnalyticsProblemCodes.UpstreamUnavailable, + $"{provider} Analytics is temporarily unavailable.") + }; + } } -public sealed class VercelAnalyticsExceptionFilter : IExceptionFilter +public sealed class WebAnalyticsExceptionFilter : IExceptionFilter { public void OnException(ExceptionContext context) { @@ -93,10 +102,10 @@ public void OnException(ExceptionContext context) return; } - var problem = VercelAnalyticsProblemFactory.FromException(context.Exception); + var problem = WebAnalyticsProblemFactory.FromException(context.Exception); if (problem is null) return; - context.Result = VercelAnalyticsProblemFactory.CreateResult(problem); + context.Result = WebAnalyticsProblemFactory.CreateResult(problem); context.ExceptionHandled = true; } } diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs index ca70de7..b7bcbb8 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs @@ -14,11 +14,11 @@ namespace TheBuilder.WebAnalytics.Controllers; [ApiExplorerSettings(GroupName = "TheBuilder.WebAnalytics")] public sealed class WebAnalyticsSettingsApiController( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - VercelAnalyticsSettingsStore settingsStore, - VercelAnalyticsConnectionRegistry registry, - IOptions serverOptions, - IVercelAnalyticsClient vercelClient, - IVercelProjectNameService projectNames) : WebAnalyticsApiControllerBase + WebAnalyticsSettingsStore settingsStore, + AnalyticsConnectionRegistry registry, + IOptions serverOptions, + IAnalyticsProviderClientResolver providerClients, + IAnalyticsConnectionNameService projectNames) : WebAnalyticsApiControllerBase { [HttpGet("settings")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -38,25 +38,33 @@ public async Task> SaveSettings( if (!IsAdministrator()) return Forbid(); if (!TimeSpan.TryParse(request.CacheDuration, out var cacheDuration)) return InvalidSettings(["Cache duration must use the format hh:mm:ss."]); + var existingProviders = settingsStore.Get().Connections.ToDictionary(connection => connection.Key, connection => connection.Provider); + var changedProvider = request.Connections.FirstOrDefault(connection => + existingProviders.TryGetValue(connection.Key, out var existingProvider) && existingProvider != connection.Provider); + if (changedProvider is not null) + return InvalidSettings([$"Connection '{changedProvider.Key}' cannot change analytics provider. Create a new connection instead."]); - var settings = new VercelAnalyticsSettings + var settings = new WebAnalyticsSettings { Enabled = request.Enabled, DefaultRangeDays = request.DefaultRangeDays, CacheDuration = cacheDuration, - Connections = request.Connections.Select(connection => new VercelAnalyticsConnectionSettings + Connections = request.Connections.Select(connection => new AnalyticsConnectionSettings { Key = connection.Key, DisplayName = connection.DisplayName, + Provider = connection.Provider, ProjectId = connection.ProjectId, Team = connection.Team, + SiteId = connection.SiteId, + EventPropertyNames = connection.EventPropertyNames.ToArray(), MockScenario = connection.MockScenario, DocumentRootKeys = connection.DocumentRootKeys.ToArray(), EnableAllDocumentTypes = connection.EnableAllDocumentTypes, EnabledDocumentTypeKeys = connection.EnabledDocumentTypeKeys.ToArray() }).ToList() }; - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); if (failures.Count > 0) return InvalidSettings(failures); settingsStore.Save(settings); @@ -73,42 +81,33 @@ public async Task> TestConnection( var connection = registry.Get(key); if (connection is null) return NotFound(); if (!connection.IsConfigured) - return Ok(new AnalyticsConnectionTestResult(false, "Add a server-side Vercel access token.")); + return Ok(new AnalyticsConnectionTestResult(false, $"Add a server-side {connection.Provider} credential and identifier.")); try { var now = DateTimeOffset.UtcNow; - await vercelClient.CountAsync( + await providerClients.Get(connection).GetTotalsAsync( connection, new AnalyticsQuery(key, now.AddDays(-1), now, AnalyticsInterval.Hour), cancellationToken); return Ok(new AnalyticsConnectionTestResult(true, "The analytics connection is ready.")); } - catch (VercelAnalyticsApiException exception) + catch (AnalyticsProviderApiException exception) { - var message = exception.StatusCode switch - { - System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden => - "Vercel rejected the token or its project/team permissions.", - System.Net.HttpStatusCode.PaymentRequired => - "Web Analytics is unavailable for the current Vercel plan or reporting window.", - System.Net.HttpStatusCode.BadRequest => - "Vercel rejected the project or team configuration.", - _ => "Vercel Analytics is temporarily unavailable." - }; + var message = AnalyticsProviderCatalog.Default.Get(exception.Provider).ConnectionTestFailure(exception.StatusCode); return Ok(new AnalyticsConnectionTestResult(false, message)); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return Ok(new AnalyticsConnectionTestResult(false, "Vercel Analytics did not respond before the request timed out.")); + return Ok(new AnalyticsConnectionTestResult(false, $"{connection.Provider} Analytics did not respond before the request timed out.")); } catch (HttpRequestException) { - return Ok(new AnalyticsConnectionTestResult(false, "Vercel Analytics could not be reached.")); + return Ok(new AnalyticsConnectionTestResult(false, $"{connection.Provider} Analytics could not be reached.")); } catch (System.Text.Json.JsonException) { - return Ok(new AnalyticsConnectionTestResult(false, "Vercel returned an unexpected analytics response.")); + return Ok(new AnalyticsConnectionTestResult(false, $"{connection.Provider} returned an unexpected analytics response.")); } } @@ -116,7 +115,6 @@ private async Task CreateResponseAsync(CancellationTo { var settings = settingsStore.Get(); var serverConfiguration = serverOptions.Value; - var vercelConfiguration = serverConfiguration.Providers.Vercel; var connections = registry.Connections.ToDictionary(connection => connection.Key); var responseTasks = settings.Connections.Select(async connection => { @@ -124,24 +122,36 @@ private async Task CreateResponseAsync(CancellationTo var displayName = connection.IsMock ? connection.DisplayName : registered is null - ? connection.ProjectId + ? AnalyticsProviderCatalog.Default.Get(connection.Provider).GetIdentifier(connection) : await projectNames.GetDisplayNameAsync(registered, cancellationToken); - return new AnalyticsConnectionSettingsResponse( - connection.Key, - displayName, - connection.ProjectId, - connection.Team, - connection.DocumentRootKeys, - connection.EnableAllDocumentTypes, - connection.EnabledDocumentTypeKeys, - registered?.HasAccessToken is true, - !string.IsNullOrWhiteSpace(serverConfiguration.ConnectionAccessTokens.GetValueOrDefault(connection.Key.ToString())), - connection.MockScenario); + return new AnalyticsConnectionSettingsResponse + { + Key = connection.Key, + DisplayName = displayName, + Provider = connection.Provider, + ProjectId = connection.ProjectId, + Team = connection.Team, + SiteId = connection.SiteId, + EventPropertyNames = connection.EventPropertyNames, + DocumentRootKeys = connection.DocumentRootKeys, + EnableAllDocumentTypes = connection.EnableAllDocumentTypes, + EnabledDocumentTypeKeys = connection.EnabledDocumentTypeKeys, + HasAccessToken = registered?.HasAccessToken is true, + HasAccessTokenOverride = !string.IsNullOrWhiteSpace(serverConfiguration.ConnectionAccessTokens.GetValueOrDefault(connection.Key.ToString())), + MockScenario = connection.MockScenario + }; }); var responseConnections = await Task.WhenAll(responseTasks); return new AnalyticsSettingsResponse( settings.Enabled, - !string.IsNullOrWhiteSpace(vercelConfiguration.AccessToken), + AnalyticsProviderCatalog.Default.Definitions + .Select(definition => definition.ToDescriptor()) + .ToArray(), + AnalyticsProviderCatalog.Default.Definitions + .Select(definition => new AnalyticsProviderTokenStatus( + definition.Provider, + !string.IsNullOrWhiteSpace(definition.GetAccessToken(serverConfiguration)))) + .ToArray(), registry.MockConnectionsEnabled, settings.DefaultRangeDays, settings.CacheDuration.ToString("c"), diff --git a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs index 4cb4775..68186e9 100644 --- a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs +++ b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using System.Globalization; using Microsoft.AspNetCore.Mvc; +using TheBuilder.WebAnalytics.Configuration; namespace TheBuilder.WebAnalytics.Models; @@ -8,6 +10,13 @@ public sealed class AnalyticsProblemDetails : ProblemDetails public string Code { get; init; } = string.Empty; } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AnalyticsProvider +{ + Vercel, + Plausible +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum AnalyticsInterval { @@ -17,6 +26,13 @@ public enum AnalyticsInterval Month } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AnalyticsTrafficMetric +{ + Visitors, + PageViews +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum MockAnalyticsScenario { @@ -32,6 +48,7 @@ public enum AnalyticsDimension RequestPath, Route, ReferrerHostname, + Referrer, Country, DeviceType, BrowserName, @@ -44,6 +61,15 @@ public enum AnalyticsDimension EventName } +public sealed record AnalyticsCapabilities( + IReadOnlyList Dimensions, + bool Events, + bool EventDetails, + bool EventProperties, + bool GlobalEventFiltering, + bool Flags, + bool BreakdownOrdering); + public sealed record AnalyticsQuery( Guid Connection, DateTimeOffset From, @@ -76,7 +102,13 @@ public sealed record AnalyticsEventDataFilter(string Property, string Value); public sealed record AnalyticsTotals(long PageViews, long Visitors); -public sealed record AnalyticsPoint(DateTimeOffset Timestamp, long PageViews, long Visitors); +public sealed record AnalyticsPoint(string Timestamp, long PageViews, long Visitors) +{ + public AnalyticsPoint(DateTimeOffset timestamp, long pageViews, long visitors) + : this(timestamp.ToString("O", CultureInfo.InvariantCulture), pageViews, visitors) + { + } +} public sealed record AnalyticsSummary( AnalyticsTotals Totals, @@ -113,6 +145,8 @@ public sealed record AnalyticsEventDetails( public sealed record AnalyticsConnectionSummary( Guid Key, string DisplayName, + AnalyticsProvider Provider, + AnalyticsCapabilities Capabilities, bool IsDefault, bool IsConfigured, string? BaseUrl, @@ -125,6 +159,8 @@ public sealed record AnalyticsConnectionsResponse( public sealed record AnalyticsDocumentRoute( Guid Connection, + AnalyticsProvider Provider, + AnalyticsCapabilities Capabilities, string Culture, string Hostname, string Path, @@ -134,23 +170,38 @@ public sealed record AnalyticsDocumentRoute( public sealed record AnalyticsSettingsResponse( bool Enabled, - bool HasAccessToken, + IReadOnlyList Providers, + IReadOnlyList ProviderTokens, bool CanCreateMockConnections, int DefaultRangeDays, string CacheDuration, IReadOnlyList Connections); -public sealed record AnalyticsConnectionSettingsResponse( - Guid Key, - string DisplayName, - string ProjectId, - string? Team, - IReadOnlyList DocumentRootKeys, - bool EnableAllDocumentTypes, - IReadOnlyList EnabledDocumentTypeKeys, - bool HasAccessToken, - bool HasAccessTokenOverride, - MockAnalyticsScenario? MockScenario); +public sealed record AnalyticsProviderDescriptor( + AnalyticsProvider Provider, + string Description, + string LogoSlug, + AnalyticsIdentifierFieldDescriptor Identifier, + AnalyticsOptionalFieldDescriptor? Team, + AnalyticsCredentialDescriptor Credential, + AnalyticsEventPropertyDescriptor? EventProperties); + +public sealed class AnalyticsConnectionSettingsResponse +{ + public required Guid Key { get; init; } + public required string DisplayName { get; init; } + public required AnalyticsProvider Provider { get; init; } + public required string ProjectId { get; init; } + public string? Team { get; init; } + public required string SiteId { get; init; } + public IReadOnlyList EventPropertyNames { get; init; } = []; + public required IReadOnlyList DocumentRootKeys { get; init; } + public required bool EnableAllDocumentTypes { get; init; } + public required IReadOnlyList EnabledDocumentTypeKeys { get; init; } + public required bool HasAccessToken { get; init; } + public required bool HasAccessTokenOverride { get; init; } + public MockAnalyticsScenario? MockScenario { get; init; } +} public sealed record UpdateAnalyticsSettingsRequest( bool Enabled, @@ -158,14 +209,23 @@ public sealed record UpdateAnalyticsSettingsRequest( string CacheDuration, IReadOnlyList Connections); -public sealed record UpdateAnalyticsConnectionRequest( - Guid Key, - string DisplayName, - string ProjectId, - string? Team, - MockAnalyticsScenario? MockScenario, - IReadOnlyList DocumentRootKeys, - bool EnableAllDocumentTypes, - IReadOnlyList EnabledDocumentTypeKeys); +public sealed class UpdateAnalyticsConnectionRequest +{ + public required Guid Key { get; init; } + public required string DisplayName { get; init; } + public required AnalyticsProvider Provider { get; init; } + public required string ProjectId { get; init; } + public string? Team { get; init; } + public required string SiteId { get; init; } + public IReadOnlyList EventPropertyNames { get; init; } = []; + public MockAnalyticsScenario? MockScenario { get; init; } + public required IReadOnlyList DocumentRootKeys { get; init; } + public required bool EnableAllDocumentTypes { get; init; } + public required IReadOnlyList EnabledDocumentTypeKeys { get; init; } +} + +public sealed record AnalyticsProviderTokenStatus( + AnalyticsProvider Provider, + bool HasAccessToken); public sealed record AnalyticsConnectionTestResult(bool Success, string Message); diff --git a/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs new file mode 100644 index 0000000..4c2fd57 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs @@ -0,0 +1,53 @@ +using System.Net; +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Providers; + +internal static class PlausibleProvider +{ + internal static AnalyticsProviderDefinition Definition { get; } = new( + AnalyticsProvider.Plausible, + AnalyticsProviderCapabilities.FromClient( + [ + AnalyticsDimension.RequestPath, + AnalyticsDimension.Referrer, + AnalyticsDimension.Country, + AnalyticsDimension.DeviceType, + AnalyticsDimension.BrowserName, + AnalyticsDimension.OsName, + AnalyticsDimension.UtmSource, + AnalyticsDimension.UtmMedium, + AnalyticsDimension.UtmCampaign, + AnalyticsDimension.UtmTerm, + AnalyticsDimension.UtmContent, + AnalyticsDimension.EventName + ], + globalEventFiltering: true, + breakdownOrdering: true), + new( + AnalyticsConnectionIdentifier.SiteId, + "Use the domain configured in your Plausible site settings.", + "a Plausible site ID"), + new( + "Sites using Plausible Analytics", + "plausible", + null, + new("Stats API key", "Configure a Plausible Stats API key in the server settings.", "https://plausible.io/docs/stats-api"), + new("event properties", "Optional custom event property names configured for this Plausible site.", 20, 100)), + options => options.Providers.Plausible.AccessToken, + invalidQueryStatuses: new HashSet { HttpStatusCode.BadRequest, HttpStatusCode.NotFound }, + fallbackBaseUrl: GetSiteBaseUrl); + + internal static AnalyticsProviderRegistration Registration { get; } = + AnalyticsProviderRegistration.Create(Definition, new Uri("https://plausible.io/")); + + private static string? GetSiteBaseUrl(AnalyticsConnection connection) + { + var siteId = connection.SiteId.Trim(); + return Uri.CheckHostName(siteId) == UriHostNameType.Unknown + ? null + : $"https://{siteId}"; + } +} diff --git a/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs b/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs new file mode 100644 index 0000000..f45d0b6 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs @@ -0,0 +1,43 @@ +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Providers; + +internal static class VercelProvider +{ + internal static AnalyticsProviderDefinition Definition { get; } = new( + AnalyticsProvider.Vercel, + AnalyticsProviderCapabilities.FromClient( + [ + AnalyticsDimension.RequestPath, + AnalyticsDimension.Route, + AnalyticsDimension.ReferrerHostname, + AnalyticsDimension.Country, + AnalyticsDimension.DeviceType, + AnalyticsDimension.BrowserName, + AnalyticsDimension.OsName, + AnalyticsDimension.UtmSource, + AnalyticsDimension.UtmMedium, + AnalyticsDimension.UtmCampaign, + AnalyticsDimension.UtmTerm, + AnalyticsDimension.UtmContent, + AnalyticsDimension.EventName + ], + globalEventFiltering: false, + breakdownOrdering: false), + new( + AnalyticsConnectionIdentifier.ProjectId, + "Use the project ID from your Vercel project settings.", + "a Vercel project ID"), + new( + "Projects using Vercel Web Analytics", + "vercel", + new("team", "Team ID or slug", "Optional team slug or ID for projects owned by a Vercel team."), + new("access token", "Configure a Vercel access token in the server settings.", "https://vercel.com/docs/rest-api"), + null), + options => options.Providers.Vercel.AccessToken); + + internal static AnalyticsProviderRegistration Registration { get; } = + AnalyticsProviderRegistration.Create(Definition, new Uri("https://api.vercel.com/")); +} diff --git a/src/TheBuilder.WebAnalytics/Services/VercelProjectNameService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsConnectionNameService.cs similarity index 64% rename from src/TheBuilder.WebAnalytics/Services/VercelProjectNameService.cs rename to src/TheBuilder.WebAnalytics/Services/AnalyticsConnectionNameService.cs index cb86934..21341c3 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelProjectNameService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsConnectionNameService.cs @@ -4,30 +4,35 @@ namespace TheBuilder.WebAnalytics.Services; -public interface IVercelProjectNameService +public interface IAnalyticsConnectionNameService { Task GetDisplayNameAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, CancellationToken cancellationToken); } -public sealed class VercelProjectNameService( - IVercelAnalyticsClient client, - IMemoryCache cache) : IVercelProjectNameService +public sealed class AnalyticsConnectionNameService( + IAnalyticsProviderClientResolver clients, + IMemoryCache cache) : IAnalyticsConnectionNameService { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); public async Task GetDisplayNameAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, CancellationToken cancellationToken) { - var fallback = FirstNonEmpty(connection.ProjectId, connection.DisplayName, connection.Key.ToString()); + var fallback = FirstNonEmpty( + AnalyticsProviderCatalog.Default.Get(connection.Provider).GetIdentifier(connection), + connection.DisplayName, + connection.Key.ToString()); if (!connection.IsConfigured) return fallback; var cacheKey = string.Join(':', - "vercel-project-name", + "analytics-connection-name", connection.Key, + connection.Provider, connection.ProjectId, + connection.SiteId, connection.Team ?? string.Empty); try @@ -35,14 +40,14 @@ public async Task GetDisplayNameAsync( return await cache.GetOrCreateAsync(cacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; - return await client.GetProjectNameAsync(connection, cancellationToken); + return await clients.Get(connection).GetDisplayNameAsync(connection, cancellationToken); }) ?? fallback; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return fallback; } - catch (VercelAnalyticsApiException) + catch (AnalyticsProviderApiException) { return fallback; } diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs index 0252876..5df6dad 100644 --- a/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsDocumentRouteService.cs @@ -8,7 +8,7 @@ namespace TheBuilder.WebAnalytics.Services; public interface IAnalyticsDocumentRouteService { Task GetConnectionBaseUrlAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, CancellationToken cancellationToken); Task> GetRoutesAsync( @@ -20,10 +20,10 @@ Task> GetRoutesAsync( public sealed class AnalyticsDocumentRouteService( IContentService contentService, IAnalyticsPublishedContentAccessor publishedContent, - VercelAnalyticsConnectionRegistry registry) : IAnalyticsDocumentRouteService + AnalyticsConnectionRegistry registry) : IAnalyticsDocumentRouteService { public async Task GetConnectionBaseUrlAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, CancellationToken cancellationToken) { foreach (var rootKey in connection.DocumentRootKeys) @@ -33,7 +33,9 @@ public sealed class AnalyticsDocumentRouteService( if (baseUrl is not null) return baseUrl; } - return null; + return AnalyticsProviderCatalog.Default + .Get(connection.Provider) + .GetFallbackBaseUrl(connection); } public async Task> GetRoutesAsync( @@ -58,6 +60,8 @@ public async Task> GetRoutesAsync( routes.Add(new AnalyticsDocumentRoute( connection.Key, + connection.Provider, + connection.Capabilities, publishedRoute.Culture, publishedRoute.Hostname, publishedRoute.Path, @@ -69,7 +73,7 @@ public async Task> GetRoutesAsync( return routes; } - private VercelAnalyticsConnection? FindRootConnection(IContent content) + private AnalyticsConnection? FindRootConnection(IContent content) { var ancestorKeys = new List(); var current = content; diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderApiException.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderApiException.cs new file mode 100644 index 0000000..57c95ff --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderApiException.cs @@ -0,0 +1,14 @@ +using System.Net; +using TheBuilder.WebAnalytics.Models; + +namespace TheBuilder.WebAnalytics.Services; + +public sealed class AnalyticsProviderApiException( + HttpStatusCode statusCode, + AnalyticsProvider provider) + : Exception($"{provider} Analytics returned HTTP {(int)statusCode}.") +{ + public HttpStatusCode StatusCode { get; } = statusCode; + + public AnalyticsProvider Provider { get; } = provider; +} diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderClientResolver.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderClientResolver.cs new file mode 100644 index 0000000..844540f --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderClientResolver.cs @@ -0,0 +1,42 @@ +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; + +namespace TheBuilder.WebAnalytics.Services; + +public sealed class AnalyticsProviderClientResolver( + IEnumerable providerClients, + MockAnalyticsClient mockClient) : IAnalyticsProviderClientResolver +{ + private readonly IReadOnlyDictionary _providerClients = + BuildProviderClients(providerClients); + + public IAnalyticsProviderClient Get(AnalyticsConnection connection) => + connection.IsMock + ? mockClient + : _providerClients.TryGetValue(connection.Provider, out var client) + ? client + : throw new ArgumentOutOfRangeException(nameof(connection), connection.Provider, "Unsupported analytics provider."); + + public TCapability Get(AnalyticsConnection connection) + where TCapability : class => + Get(connection) as TCapability + ?? throw new InvalidOperationException( + $"The {connection.Provider} analytics client does not support {typeof(TCapability).Name}."); + + private static IReadOnlyDictionary BuildProviderClients( + IEnumerable clients) + { + var clientList = clients.ToArray(); + var duplicates = clientList.GroupBy(client => client.Definition.Provider).FirstOrDefault(group => group.Count() > 1); + if (duplicates is not null) + throw new InvalidOperationException($"Multiple analytics clients are registered for {duplicates.Key}."); + + var byProvider = clientList.ToDictionary(client => client.Definition.Provider); + foreach (var definition in AnalyticsProviderCatalog.Default.Definitions) + { + if (!byProvider.ContainsKey(definition.Provider)) + throw new InvalidOperationException($"No analytics client is registered for {definition.Provider}."); + } + return byProvider; + } +} diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsRequestGate.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderRequestGate.cs similarity index 83% rename from src/TheBuilder.WebAnalytics/Services/VercelAnalyticsRequestGate.cs rename to src/TheBuilder.WebAnalytics/Services/AnalyticsProviderRequestGate.cs index dd3a4d9..d6cbde2 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsRequestGate.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsProviderRequestGate.cs @@ -1,17 +1,17 @@ namespace TheBuilder.WebAnalytics.Services; -public sealed class VercelAnalyticsRequestGate : IDisposable +public sealed class AnalyticsProviderRequestGate : IDisposable { internal const int DefaultMaximumConcurrentRequests = 8; private readonly SemaphoreSlim _concurrency; - public VercelAnalyticsRequestGate() + public AnalyticsProviderRequestGate() : this(DefaultMaximumConcurrentRequests) { } - internal VercelAnalyticsRequestGate(int maximumConcurrentRequests) + internal AnalyticsProviderRequestGate(int maximumConcurrentRequests) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumConcurrentRequests); _concurrency = new SemaphoreSlim(maximumConcurrentRequests, maximumConcurrentRequests); diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsReportService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs similarity index 58% rename from src/TheBuilder.WebAnalytics/Services/VercelAnalyticsReportService.cs rename to src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs index 18edfd2..45d9855 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsReportService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs @@ -5,9 +5,9 @@ namespace TheBuilder.WebAnalytics.Services; -public sealed class VercelAnalyticsReportService( - VercelAnalyticsConnectionRegistry registry, - IVercelAnalyticsClient client, +public sealed class AnalyticsReportService( + AnalyticsConnectionRegistry registry, + IAnalyticsProviderClientResolver clients, AnalyticsReportCache cache) { public async Task GetSummaryAsync( @@ -17,17 +17,16 @@ public sealed class VercelAnalyticsReportService( var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); if (connection is null || !connection.IsConfigured) return null; - var cacheKey = $"vercel-analytics:{snapshot.Revision}:summary:{Normalize(query)}"; + var client = clients.Get(connection); + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:summary:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { - var count = client.CountAsync(connection, query, operationCancellationToken); - var pageViews = client.GetPageViewTotalAsync(connection, query, operationCancellationToken); + var totals = client.GetTotalsAsync(connection, query, operationCancellationToken); var previousTotals = TryGetPreviousTotalsAsync(connection, query, operationCancellationToken); var trend = client.GetTrendAsync(connection, query, operationCancellationToken); - await Task.WhenAll(count, pageViews, previousTotals, trend); + await Task.WhenAll(totals, previousTotals, trend); var points = await trend; - var totals = new AnalyticsTotals(await pageViews, (await count).Visitors); - return new AnalyticsSummary(totals, await previousTotals, points); + return new AnalyticsSummary(await totals, await previousTotals, points); }, cancellationToken); } @@ -36,16 +35,18 @@ public sealed class VercelAnalyticsReportService( AnalyticsDimension dimension, int limit, string? search, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) { var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); - if (connection is null || !connection.IsConfigured) return null; + if (connection is null || !connection.IsConfigured || !connection.Capabilities.Dimensions.Contains(dimension)) return null; + var client = clients.Get(connection); var normalizedSearch = search?.Trim(); - var cacheKey = $"vercel-analytics:{snapshot.Revision}:breakdown:{dimension}:{limit}:{normalizedSearch}:{Normalize(query)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:breakdown:{dimension}:{orderBy}:{limit}:{normalizedSearch}:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { - var rows = await client.GetBreakdownAsync(connection, query, dimension, limit, normalizedSearch, operationCancellationToken); + var rows = await client.GetBreakdownAsync(connection, query, dimension, limit, normalizedSearch, operationCancellationToken, orderBy); return new AnalyticsBreakdown(dimension, rows); }, cancellationToken); } @@ -58,9 +59,10 @@ public sealed class VercelAnalyticsReportService( { var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); - if (connection is null || !connection.IsConfigured) return null; + if (connection is null || !connection.IsConfigured || !connection.Capabilities.Events) return null; + var client = clients.Get(connection); var normalizedSearch = search?.Trim(); - var cacheKey = $"vercel-analytics:{snapshot.Revision}:events:{limit}:{normalizedSearch}:{Normalize(query)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:events:{limit}:{normalizedSearch}:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { var rows = await client.GetEventsAsync(connection, query, limit, normalizedSearch, operationCancellationToken); @@ -76,10 +78,11 @@ public sealed class VercelAnalyticsReportService( { var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); - if (connection is null || !connection.IsConfigured) return null; + if (connection is null || !connection.IsConfigured || !connection.Capabilities.Flags) return null; + var client = clients.Get(connection); var normalizedFlagKey = string.IsNullOrWhiteSpace(flagKey) ? null : flagKey.Trim(); var flagKeyCacheKey = EncodeCachePart(normalizedFlagKey ?? string.Empty); - var cacheKey = $"vercel-analytics:{snapshot.Revision}:flags:{flagKeyCacheKey}:{limit}:{Normalize(query)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:flags:{flagKeyCacheKey}:{limit}:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { var rows = await client.GetFlagsAsync(connection, query, normalizedFlagKey, limit, operationCancellationToken); @@ -95,25 +98,69 @@ public sealed class VercelAnalyticsReportService( { var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); - if (connection is null || !connection.IsConfigured) return null; + if (connection is null || !connection.IsConfigured || !connection.Capabilities.EventDetails) return null; + var propertiesClient = connection.Capabilities.EventProperties + ? clients.Get(connection) + : null; + var client = propertiesClient ?? clients.Get(connection); var normalizedEventName = eventName.Trim(); var eventDataCacheKey = eventDataFilter is null ? string.Empty : $":{EncodeCachePart(eventDataFilter.Property)}:{EncodeCachePart(eventDataFilter.Value)}"; - var cacheKey = $"vercel-analytics:{snapshot.Revision}:event-details:{EncodeCachePart(normalizedEventName)}{eventDataCacheKey}:{Normalize(query)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:event-details:{EncodeCachePart(normalizedEventName)}{eventDataCacheKey}:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { - var totals = client.CountEventsAsync(connection, query, normalizedEventName, eventDataFilter, operationCancellationToken); - var propertyNamesTask = client.GetEventPropertyNamesAsync(connection, query, normalizedEventName, eventDataFilter, operationCancellationToken); - await Task.WhenAll(totals, propertyNamesTask); - var propertyNames = await propertyNamesTask; - var properties = propertyNames - .Select(propertyName => new AnalyticsEventProperty(propertyName, [])) - .ToArray(); - return new AnalyticsEventDetails(normalizedEventName, await totals, properties); + var totals = eventDataFilter is null + ? client.CountEventsAsync(connection, query, normalizedEventName, operationCancellationToken) + : propertiesClient?.CountFilteredEventsAsync(connection, query, normalizedEventName, eventDataFilter, operationCancellationToken) + ?? throw new InvalidOperationException($"{connection.Provider} does not support event property filters."); + var propertiesTask = GetEventPropertiesAsync( + connection, + snapshot.Revision, + snapshot.Settings.CacheDuration, + query, + normalizedEventName, + propertiesClient, + eventDataFilter, + operationCancellationToken); + await Task.WhenAll(totals, propertiesTask); + return new AnalyticsEventDetails(normalizedEventName, await totals, await propertiesTask); }, cancellationToken); } + private async Task> GetEventPropertiesAsync( + AnalyticsConnection connection, + long revision, + TimeSpan cacheDuration, + AnalyticsQuery query, + string eventName, + IAnalyticsEventPropertiesProviderClient? client, + AnalyticsEventDataFilter? eventDataFilter, + CancellationToken cancellationToken) + { + if (client is null) return []; + if (client is not IAnalyticsEventPropertyDiscoveryProviderClient discoveryClient) + { + var propertyNames = await client.GetEventPropertyNamesAsync(connection, query, eventName, eventDataFilter, cancellationToken); + return propertyNames.Select(propertyName => new AnalyticsEventProperty(propertyName, [])).ToArray(); + } + + var eventDataCacheKey = eventDataFilter is null + ? string.Empty + : $":{EncodeCachePart(eventDataFilter.Property)}:{EncodeCachePart(eventDataFilter.Value)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{revision}:event-property-discovery{eventDataCacheKey}:{Normalize(query)}"; + var propertiesByEvent = await GetOrCreateAsync( + cacheKey, + cacheDuration, + operationCancellationToken => discoveryClient.DiscoverEventPropertiesAsync( + connection, + query, + eventDataFilter, + operationCancellationToken), + cancellationToken); + return propertiesByEvent.TryGetValue(eventName, out var properties) ? properties : []; + } + public async Task GetEventPropertyValuesAsync( AnalyticsQuery query, string eventName, @@ -125,7 +172,8 @@ public sealed class VercelAnalyticsReportService( { var snapshot = registry.Capture(); var connection = snapshot.Get(query.Connection); - if (connection is null || !connection.IsConfigured) return null; + if (connection is null || !connection.IsConfigured || !connection.Capabilities.EventProperties) return null; + var client = clients.Get(connection); var normalizedEventName = eventName.Trim(); var normalizedPropertyName = propertyName.Trim(); var normalizedSearch = search?.Trim(); @@ -135,7 +183,7 @@ public sealed class VercelAnalyticsReportService( var eventNameCacheKey = EncodeCachePart(normalizedEventName); var propertyNameCacheKey = EncodeCachePart(normalizedPropertyName); var searchCacheKey = EncodeCachePart(normalizedSearch ?? string.Empty); - var cacheKey = $"vercel-analytics:{snapshot.Revision}:event-property-values:{eventNameCacheKey}:{propertyNameCacheKey}:{limit}:{searchCacheKey}{eventDataCacheKey}:{Normalize(query)}"; + var cacheKey = $"web-analytics:{connection.Provider}:{snapshot.Revision}:event-property-values:{eventNameCacheKey}:{propertyNameCacheKey}:{limit}:{searchCacheKey}{eventDataCacheKey}:{Normalize(query)}"; return await GetOrCreateAsync(cacheKey, snapshot.Settings.CacheDuration, async operationCancellationToken => { var values = await client.GetEventPropertyValuesAsync( @@ -170,7 +218,7 @@ private static string Normalize(AnalyticsQuery query) } private async Task TryGetPreviousTotalsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { @@ -187,11 +235,10 @@ private static string Normalize(AnalyticsQuery query) try { - var count = client.CountAsync(connection, previousQuery, cancellationToken); - var pageViews = client.GetPageViewTotalAsync(connection, previousQuery, cancellationToken); - comparison = Task.WhenAll(count, pageViews); + var totals = clients.Get(connection).GetTotalsAsync(connection, previousQuery, cancellationToken); + comparison = totals; await comparison; - return new AnalyticsTotals(await pageViews, (await count).Visitors); + return await totals; } catch (Exception failure) { @@ -209,5 +256,6 @@ private static string Normalize(AnalyticsQuery query) } private static bool IsOptionalComparisonFailure(Exception failure) => - failure is VercelAnalyticsApiException or HttpRequestException or JsonException or OperationCanceledException; + failure is AnalyticsProviderApiException or HttpRequestException or JsonException or OperationCanceledException; + } diff --git a/src/TheBuilder.WebAnalytics/Services/IVercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/IAnalyticsProviderClient.cs similarity index 52% rename from src/TheBuilder.WebAnalytics/Services/IVercelAnalyticsClient.cs rename to src/TheBuilder.WebAnalytics/Services/IAnalyticsProviderClient.cs index 02eb37e..cb889e8 100644 --- a/src/TheBuilder.WebAnalytics/Services/IVercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/IAnalyticsProviderClient.cs @@ -3,65 +3,72 @@ namespace TheBuilder.WebAnalytics.Services; -public interface IVercelAnalyticsClient +public interface IAnalyticsProviderClient { - Task GetProjectNameAsync( - VercelAnalyticsConnection connection, - CancellationToken cancellationToken); + AnalyticsProviderDefinition Definition { get; } - Task CountAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, + Task GetDisplayNameAsync( + AnalyticsConnection connection, CancellationToken cancellationToken); - Task GetPageViewTotalAsync( - VercelAnalyticsConnection connection, + Task GetTotalsAsync( + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken); Task> GetTrendAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken); Task> GetBreakdownAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension dimension, int limit, string? search, - CancellationToken cancellationToken); + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null); - Task CountEventsAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - string eventName, - AnalyticsEventDataFilter? eventDataFilter, - CancellationToken cancellationToken); +} +public interface IAnalyticsEventsProviderClient +{ Task> GetEventsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, int limit, string? search, CancellationToken cancellationToken); +} - Task> GetFlagsAsync( - VercelAnalyticsConnection connection, +public interface IAnalyticsEventDetailsProviderClient : IAnalyticsEventsProviderClient +{ + Task CountEventsAsync( + AnalyticsConnection connection, AnalyticsQuery query, - string? flagKey, - int limit, + string eventName, + CancellationToken cancellationToken); +} + +public interface IAnalyticsEventPropertiesProviderClient : IAnalyticsEventDetailsProviderClient +{ + Task CountFilteredEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + AnalyticsEventDataFilter eventDataFilter, CancellationToken cancellationToken); Task> GetEventPropertyNamesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken); Task> GetEventPropertyValuesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, string propertyName, @@ -70,3 +77,30 @@ Task> GetEventPropertyValuesAsync( AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken); } + +public interface IAnalyticsEventPropertyDiscoveryProviderClient : IAnalyticsEventPropertiesProviderClient +{ + Task>> DiscoverEventPropertiesAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsEventDataFilter? eventDataFilter, + CancellationToken cancellationToken); +} + +public interface IAnalyticsFlagsProviderClient +{ + Task> GetFlagsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string? flagKey, + int limit, + CancellationToken cancellationToken); +} + +public interface IAnalyticsProviderClientResolver +{ + IAnalyticsProviderClient Get(AnalyticsConnection connection); + + TCapability Get(AnalyticsConnection connection) + where TCapability : class; +} diff --git a/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs similarity index 87% rename from src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs rename to src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs index e1dda1b..2f22cc9 100644 --- a/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs @@ -1,9 +1,13 @@ using TheBuilder.WebAnalytics.Configuration; using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Providers; namespace TheBuilder.WebAnalytics.Services; -public sealed class MockVercelAnalyticsClient : IVercelAnalyticsClient +public sealed class MockAnalyticsClient : + IAnalyticsProviderClient, + IAnalyticsEventPropertiesProviderClient, + IAnalyticsFlagsProviderClient { private const double DemoDailyGrowthRate = 0.0008d; private const double DemoGrowthWindowDays = 1825d; @@ -41,27 +45,28 @@ public sealed class MockVercelAnalyticsClient : IVercelAnalyticsClient new("personalised-homepage", 4760, 3010) ]; - public Task GetProjectNameAsync(VercelAnalyticsConnection connection, CancellationToken cancellationToken) + public AnalyticsProviderDefinition Definition => VercelProvider.Definition; + + public Task GetDisplayNameAsync(AnalyticsConnection connection, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(connection.DisplayName); } - public Task CountAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) + public Task GetTotalsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + CancellationToken cancellationToken) => CountAsync(connection, query, cancellationToken); + + public Task CountAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var scale = QueryScale(connection, query); return Task.FromResult(new AnalyticsTotals(Scale(29430, scale), Scale(17260, scale))); } - public Task GetPageViewTotalAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(Scale(29430, QueryScale(connection, query))); - } - public Task> GetTrendAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { @@ -86,12 +91,13 @@ public Task> GetTrendAsync( } public Task> GetBreakdownAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension dimension, int limit, string? search, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) { cancellationToken.ThrowIfCancellationRequested(); var scenario = Scenario(connection); @@ -109,7 +115,22 @@ public Task> GetBreakdownAsync( } public Task CountEventsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + CancellationToken cancellationToken) => + CountEventsAsync(connection, query, eventName, null, cancellationToken); + + public Task CountFilteredEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + AnalyticsEventDataFilter eventDataFilter, + CancellationToken cancellationToken) => + CountEventsAsync(connection, query, eventName, eventDataFilter, cancellationToken); + + private Task CountEventsAsync( + AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, @@ -126,7 +147,7 @@ public Task CountEventsAsync( } public Task> GetEventsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, int limit, string? search, @@ -144,7 +165,7 @@ public Task> GetEventsAsync( } public Task> GetFlagsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string? flagKey, int limit, @@ -167,7 +188,7 @@ public Task> GetFlagsAsync( } public Task> GetEventPropertyNamesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, @@ -185,7 +206,7 @@ public Task> GetEventPropertyNamesAsync( } public Task> GetEventPropertyValuesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, string propertyName, @@ -207,13 +228,13 @@ public Task> GetEventPropertyValuesAs return Task.FromResult>(Filter(rows, search, limit, row => row.Value)); } - private static bool HasEvents(VercelAnalyticsConnection connection) => + private static bool HasEvents(AnalyticsConnection connection) => Scenario(connection) is MockAnalyticsScenario.Complete or MockAnalyticsScenario.Events; - private static bool HasFlags(VercelAnalyticsConnection connection) => + private static bool HasFlags(AnalyticsConnection connection) => Scenario(connection) is MockAnalyticsScenario.Complete or MockAnalyticsScenario.Flags; - private static MockAnalyticsScenario Scenario(VercelAnalyticsConnection connection) => + private static MockAnalyticsScenario Scenario(AnalyticsConnection connection) => connection.MockScenario ?? throw new InvalidOperationException("A mock analytics client requires a mock connection."); private static T[] Filter(IEnumerable rows, string? search, int limit, Func value) @@ -237,7 +258,7 @@ private static IEnumerable ApplyDimensionFilter( } private static double QueryScale( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension? excludedDimension = null) { diff --git a/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs new file mode 100644 index 0000000..2b4291f --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs @@ -0,0 +1,458 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Providers; + +namespace TheBuilder.WebAnalytics.Services; + +public sealed class PlausibleAnalyticsClient( + HttpClient httpClient, + AnalyticsProviderRequestGate requestGate) : IAnalyticsProviderClient, IAnalyticsEventPropertyDiscoveryProviderClient +{ + private const string QueryPath = "api/v2/query"; + + public AnalyticsProviderDefinition Definition => PlausibleProvider.Definition; + + public Task GetDisplayNameAsync( + AnalyticsConnection connection, + CancellationToken cancellationToken) => Task.FromResult(connection.SiteId); + + public Task GetTotalsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + CancellationToken cancellationToken) => CountAsync(connection, query, cancellationToken); + + public async Task CountAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + CancellationToken cancellationToken) + { + var response = await QueryAsync(connection, query, ["pageviews", "visitors"], [], null, null, cancellationToken); + var row = SingleRow(response); + return new AnalyticsTotals(Metric(row, 0, "pageviews"), Metric(row, 1, "visitors")); + } + + public async Task> GetTrendAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + CancellationToken cancellationToken) + { + var dimension = query.Interval switch + { + AnalyticsInterval.Hour => "time:hour", + AnalyticsInterval.Day => "time:day", + AnalyticsInterval.Week => "time:week", + AnalyticsInterval.Month => "time:month", + _ => throw new ArgumentOutOfRangeException(nameof(query)) + }; + var response = await QueryAsync( + connection, + query, + ["pageviews", "visitors"], + [dimension], + null, + null, + cancellationToken, + includeTimeLabels: true); + var labels = response.Meta?.TimeLabels + ?? throw new JsonException("Plausible response did not contain requested time labels."); + if (labels.Any(string.IsNullOrWhiteSpace) || labels.Distinct(StringComparer.Ordinal).Count() != labels.Count) + throw new JsonException("Plausible returned invalid or duplicate time labels."); + if (response.Results! + .GroupBy(row => Dimension(row, 0, dimension), StringComparer.Ordinal) + .Any(group => group.Count() > 1)) + throw new JsonException("Plausible returned duplicate time buckets."); + var rows = response.Results!.ToDictionary(row => Dimension(row, 0, dimension), StringComparer.Ordinal); + if (rows.Keys.Except(labels, StringComparer.Ordinal).Any()) + throw new JsonException("Plausible returned a time bucket outside the requested labels."); + return labels.Select(label => rows.TryGetValue(label, out var row) + ? new AnalyticsPoint(NormalizeTimeLabel(label), Metric(row, 0, "pageviews"), Metric(row, 1, "visitors")) + : new AnalyticsPoint(NormalizeTimeLabel(label), 0, 0)).ToArray(); + } + + public async Task> GetBreakdownAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsDimension dimension, + int limit, + string? search, + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) + { + var plausibleDimension = ToApiDimension(dimension); + var response = await QueryAsync( + connection, + query, + ["pageviews", "visitors"], + [plausibleDimension], + limit, + string.IsNullOrWhiteSpace(search) ? null : (plausibleDimension, search.Trim()), + cancellationToken, + orderBy: orderBy switch + { + AnalyticsTrafficMetric.PageViews => "pageviews", + AnalyticsTrafficMetric.Visitors => "visitors", + null => "visitors", + _ => throw new ArgumentOutOfRangeException(nameof(orderBy)) + }); + return response.Results!.Select(row => new AnalyticsBreakdownRow( + Dimension(row, 0, plausibleDimension), + Metric(row, 0, "pageviews"), + Metric(row, 1, "visitors"))).ToArray(); + } + + public async Task> GetEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + int limit, + string? search, + CancellationToken cancellationToken) + { + const string dimension = "event:goal"; + var response = await QueryAsync( + connection, + query, + ["events", "visitors"], + [dimension], + limit, + string.IsNullOrWhiteSpace(search) ? null : (dimension, search.Trim()), + cancellationToken); + return response.Results!.Select(row => new AnalyticsEventRow( + Dimension(row, 0, dimension), + Metric(row, 0, "events"), + Metric(row, 1, "visitors"))).ToArray(); + } + + public async Task CountEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + CancellationToken cancellationToken) + { + var response = await QueryAsync( + connection, + query, + ["events", "visitors"], + [], + null, + null, + cancellationToken, + eventName: eventName); + var row = SingleRow(response); + return new AnalyticsEventTotals(Metric(row, 0, "events"), Metric(row, 1, "visitors")); + } + + public async Task CountFilteredEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + AnalyticsEventDataFilter eventDataFilter, + CancellationToken cancellationToken) + { + if (!SupportsEventProperty(connection, eventName, eventDataFilter.Property)) return new(0, 0); + var response = await QueryAsync( + connection, + query, + ["events", "visitors"], + [], + null, + null, + cancellationToken, + eventName, + eventDataFilter: eventDataFilter); + var row = SingleRow(response); + return new AnalyticsEventTotals(Metric(row, 0, "events"), Metric(row, 1, "visitors")); + } + + public Task> GetEventPropertyNamesAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + AnalyticsEventDataFilter? eventDataFilter, + CancellationToken cancellationToken) => Task.FromResult(EventPropertyNames(connection, eventName)); + + public async Task>> DiscoverEventPropertiesAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsEventDataFilter? eventDataFilter, + CancellationToken cancellationToken) + { + var propertyNames = connection.EventPropertyNames + .Concat(["url", "path"]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var discoveries = await Task.WhenAll(propertyNames.Select(async propertyName => + { + var propertyDimension = PropertyDimension(propertyName); + var response = await QueryAsync( + connection, + query, + ["events", "visitors"], + ["event:goal", propertyDimension], + null, + null, + cancellationToken, + eventDataFilter: eventDataFilter); + var propertiesByEvent = response.Results! + .Where(HasPropertyValue) + .Select(row => (Row: row, EventName: Dimension(row, 0, "event:goal"))) + .Where(item => SupportsEventProperty(connection, item.EventName, propertyName)) + .GroupBy(item => item.EventName, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => new AnalyticsEventProperty( + propertyName, + group.Select(item => new AnalyticsEventPropertyValue( + Dimension(item.Row, 1, propertyDimension), + Metric(item.Row, 0, "events"), + Metric(item.Row, 1, "visitors"))).ToArray()), + StringComparer.Ordinal); + return propertiesByEvent; + })); + + return discoveries + .SelectMany(discovery => discovery.Select(item => (EventName: item.Key, Property: item.Value))) + .GroupBy(item => item.EventName, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.Select(item => item.Property).ToArray(), + StringComparer.Ordinal); + } + + public async Task> GetEventPropertyValuesAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + string propertyName, + int limit, + string? search, + AnalyticsEventDataFilter? eventDataFilter, + CancellationToken cancellationToken) + { + if (!SupportsEventProperty(connection, eventName, propertyName)) return []; + var dimension = PropertyDimension(propertyName); + var response = await QueryAsync( + connection, + query, + ["events", "visitors"], + [dimension], + limit, + string.IsNullOrWhiteSpace(search) ? null : (dimension, search.Trim()), + cancellationToken, + eventName, + orderBy: "events", + eventDataFilter: eventDataFilter); + return response.Results!.Select(row => new AnalyticsEventPropertyValue( + Dimension(row, 0, dimension), + Metric(row, 0, "events"), + Metric(row, 1, "visitors"))).ToArray(); + } + + private async Task QueryAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string[] metrics, + string[] dimensions, + int? limit, + (string Dimension, string Value)? search, + CancellationToken cancellationToken, + string? eventName = null, + bool includeTimeLabels = false, + string? orderBy = null, + AnalyticsEventDataFilter? eventDataFilter = null) + { + var filters = BuildFilters(query, eventName, eventDataFilter); + if (search is { } searchFilter) + { + filters.Add(new PlausibleFilter("contains", searchFilter.Dimension, [searchFilter.Value])); + } + + var requestBody = new PlausibleRequest( + connection.SiteId, + metrics, + [ + query.From.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + query.To.AddTicks(-1).ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) + ], + dimensions, + filters, + limit is null ? null : new PlausiblePagination(Math.Min(limit.Value, 100), 0), + includeTimeLabels ? new PlausibleInclude(TimeLabels: true) : null, + orderBy is null ? null : [[orderBy, "desc"]]); + + using var request = new HttpRequestMessage(HttpMethod.Post, QueryPath) + { + Content = JsonContent.Create(requestBody) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.AccessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await requestGate.RunAsync( + operationToken => httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, operationToken), + cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new AnalyticsProviderApiException(response.StatusCode, AnalyticsProvider.Plausible); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is not { Results: not null }) + throw new JsonException("Plausible response did not contain results."); + if (payload.Results.Any(row => + row.Dimensions?.Count != dimensions.Length || row.Metrics?.Count != metrics.Length)) + throw new JsonException("Plausible response row did not match the requested shape."); + return payload; + } + + private static List BuildFilters( + AnalyticsQuery query, + string? eventName, + AnalyticsEventDataFilter? eventDataFilter) + { + var filters = new List(); + if (!string.IsNullOrWhiteSpace(query.RequestPath)) + { + filters.Add(new PlausibleFilter("is", "event:page", [query.RequestPath])); + } + foreach (var filter in query.Filters ?? []) + { + filters.Add(new PlausibleFilter("is", ToApiDimension(filter.Dimension), [filter.Value])); + } + if (!string.IsNullOrWhiteSpace(eventName) && + query.Filters?.Any(filter => + filter.Dimension == AnalyticsDimension.EventName && + string.Equals(filter.Value, eventName, StringComparison.Ordinal)) is not true) + { + filters.Add(new PlausibleFilter("is", "event:goal", [eventName])); + } + if (eventDataFilter is not null) + { + filters.Add(new PlausibleFilter("is", PropertyDimension(eventDataFilter.Property), [eventDataFilter.Value])); + } + return filters; + } + + private static IReadOnlyList EventPropertyNames(AnalyticsConnection connection, string eventName) + { + var builtIn = eventName switch + { + "Outbound Link: Click" or "File Download" => "url", + "404" => "path", + _ => null + }; + return (builtIn is null ? connection.EventPropertyNames : [builtIn, .. connection.EventPropertyNames]) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool SupportsEventProperty(AnalyticsConnection connection, string eventName, string propertyName) => + EventPropertyNames(connection, eventName).Contains(propertyName.Trim(), StringComparer.OrdinalIgnoreCase); + + private static string PropertyDimension(string propertyName) => $"event:props:{propertyName.Trim()}"; + + private static bool HasPropertyValue(PlausibleRow row) => + row.Dimensions is not null && + row.Dimensions.Count > 1 && + row.Dimensions[1] is { Length: > 0 } value && + !string.Equals(value, "(none)", StringComparison.OrdinalIgnoreCase); + + internal static string ToApiDimension(AnalyticsDimension dimension) => dimension switch + { + AnalyticsDimension.RequestPath => "event:page", + AnalyticsDimension.Referrer => "visit:referrer", + AnalyticsDimension.Country => "visit:country", + AnalyticsDimension.DeviceType => "visit:device", + AnalyticsDimension.BrowserName => "visit:browser", + AnalyticsDimension.OsName => "visit:os", + AnalyticsDimension.UtmSource => "visit:utm_source", + AnalyticsDimension.UtmMedium => "visit:utm_medium", + AnalyticsDimension.UtmCampaign => "visit:utm_campaign", + AnalyticsDimension.UtmTerm => "visit:utm_term", + AnalyticsDimension.UtmContent => "visit:utm_content", + AnalyticsDimension.EventName => "event:goal", + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Plausible does not support this dimension.") + }; + + private static PlausibleRow SingleRow(PlausibleResponse response) => response.Results!.Count switch + { + 0 => new PlausibleRow([], [JsonSerializer.SerializeToElement(0), JsonSerializer.SerializeToElement(0)]), + 1 => response.Results![0], + _ => throw new JsonException("Plausible aggregate response contained multiple rows.") + }; + + private static long Metric(PlausibleRow row, int index, string name) => + row.Metrics is not null && index < row.Metrics.Count && row.Metrics[index].TryGetInt64(out var value) + ? value + : throw new JsonException($"Plausible metric '{name}' was missing or invalid."); + + private static string Dimension(PlausibleRow row, int index, string name) => + row.Dimensions is not null && index < row.Dimensions.Count && !string.IsNullOrWhiteSpace(row.Dimensions[index]) + ? row.Dimensions[index] + : throw new JsonException($"Plausible dimension '{name}' was missing or invalid."); + + private static string NormalizeTimeLabel(string value) + { + if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if (DateTime.TryParseExact( + value, + ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss"], + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var localTime)) + return localTime.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture); + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var timestamp)) + return timestamp.ToString("O", CultureInfo.InvariantCulture); + throw new JsonException("Plausible returned an invalid time dimension."); + } + + private sealed record PlausibleRequest( + [property: JsonPropertyName("site_id")] string SiteId, + [property: JsonPropertyName("metrics")] string[] Metrics, + [property: JsonPropertyName("date_range")] string[] DateRange, + [property: JsonPropertyName("dimensions")] string[] Dimensions, + [property: JsonPropertyName("filters")] IReadOnlyList Filters, + [property: JsonPropertyName("pagination"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] PlausiblePagination? Pagination, + [property: JsonPropertyName("include"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] PlausibleInclude? Include, + [property: JsonPropertyName("order_by"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[][]? OrderBy); + + private sealed record PlausiblePagination( + [property: JsonPropertyName("limit")] int Limit, + [property: JsonPropertyName("offset")] int Offset); + + private sealed record PlausibleInclude( + [property: JsonPropertyName("time_labels")] bool TimeLabels); + + private sealed record PlausibleResponse( + [property: JsonPropertyName("results")] IReadOnlyList? Results, + [property: JsonPropertyName("meta")] PlausibleMeta? Meta); + + private sealed record PlausibleMeta( + [property: JsonPropertyName("time_labels")] IReadOnlyList? TimeLabels); + + private sealed record PlausibleRow( + [property: JsonPropertyName("dimensions")] IReadOnlyList? Dimensions, + [property: JsonPropertyName("metrics")] IReadOnlyList? Metrics); + + [JsonConverter(typeof(PlausibleFilterJsonConverter))] + private sealed record PlausibleFilter(string Operator, string Dimension, IReadOnlyList Values); + + private sealed class PlausibleFilterJsonConverter : JsonConverter + { + public override PlausibleFilter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException("Plausible filters are request-only values."); + + public override void Write(Utf8JsonWriter writer, PlausibleFilter value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteStringValue(value.Operator); + writer.WriteStringValue(value.Dimension); + JsonSerializer.Serialize(writer, value.Values, options); + writer.WriteEndArray(); + } + } +} diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsApiException.cs b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsApiException.cs deleted file mode 100644 index 58a9ab4..0000000 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsApiException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Net; - -namespace TheBuilder.WebAnalytics.Services; - -public sealed class VercelAnalyticsApiException(HttpStatusCode statusCode) - : Exception($"Vercel Analytics returned HTTP {(int)statusCode}.") -{ - public HttpStatusCode StatusCode { get; } = statusCode; -} diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs index 096c895..3d1922a 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs @@ -5,12 +5,16 @@ using Microsoft.AspNetCore.WebUtilities; using TheBuilder.WebAnalytics.Configuration; using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Providers; namespace TheBuilder.WebAnalytics.Services; public sealed class VercelAnalyticsClient( HttpClient httpClient, - VercelAnalyticsRequestGate requestGate) : IVercelAnalyticsClient + AnalyticsProviderRequestGate requestGate) : + IAnalyticsProviderClient, + IAnalyticsEventPropertiesProviderClient, + IAnalyticsFlagsProviderClient { private const int EventPropertyLimit = 20; private const string CountPath = "v1/query/web-analytics/visits/count"; @@ -18,8 +22,10 @@ public sealed class VercelAnalyticsClient( private const string EventCountPath = "v1/query/web-analytics/events/count"; private const string EventAggregatePath = "v1/query/web-analytics/events/aggregate"; - public async Task GetProjectNameAsync( - VercelAnalyticsConnection connection, + public AnalyticsProviderDefinition Definition => VercelProvider.Definition; + + public async Task GetDisplayNameAsync( + AnalyticsConnection connection, CancellationToken cancellationToken) { var parameters = new Dictionary(); @@ -33,8 +39,19 @@ public async Task GetProjectNameAsync( : throw new JsonException("Vercel project response did not contain a name."); } + public async Task GetTotalsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + CancellationToken cancellationToken) + { + var count = CountAsync(connection, query, cancellationToken); + var pageViews = GetPageViewTotalAsync(connection, query, cancellationToken); + await Task.WhenAll(count, pageViews); + return new AnalyticsTotals(await pageViews, (await count).Visitors); + } + public async Task CountAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { @@ -52,7 +69,7 @@ public async Task CountAsync( } public async Task GetPageViewTotalAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { @@ -68,7 +85,7 @@ public async Task GetPageViewTotalAsync( } public async Task> GetTrendAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { @@ -81,13 +98,16 @@ public async Task> GetTrendAsync( } public async Task> GetBreakdownAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension dimension, int limit, string? search, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) { + if (orderBy is not null) + throw new ArgumentException("Vercel Web Analytics does not support selecting a breakdown ordering metric.", nameof(orderBy)); var apiDimension = ToApiValue(dimension); var parameters = BuildVisitParameters(connection, query); parameters["by"] = apiDimension; @@ -103,8 +123,23 @@ public async Task> GetBreakdownAsync( ?? throw new JsonException("Vercel Analytics breakdown response did not contain data."); } - public async Task CountEventsAsync( - VercelAnalyticsConnection connection, + public Task CountEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + CancellationToken cancellationToken) => + CountEventsAsync(connection, query, eventName, null, cancellationToken); + + public Task CountFilteredEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + AnalyticsEventDataFilter eventDataFilter, + CancellationToken cancellationToken) => + CountEventsAsync(connection, query, eventName, eventDataFilter, cancellationToken); + + private async Task CountEventsAsync( + AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, @@ -123,7 +158,7 @@ public async Task CountEventsAsync( } public async Task> GetEventsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, int limit, string? search, @@ -148,7 +183,7 @@ public async Task> GetEventsAsync( } public async Task> GetFlagsAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string? flagKey, int limit, @@ -168,7 +203,7 @@ public async Task> GetFlagsAsync( } public async Task> GetEventPropertyNamesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, @@ -189,7 +224,7 @@ public async Task> GetEventPropertyNamesAsync( } public async Task> GetEventPropertyValuesAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query, string eventName, string propertyName, @@ -218,7 +253,7 @@ public async Task> GetEventPropertyVa } private async Task SendAsync( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, string path, IDictionary parameters, CancellationToken cancellationToken) @@ -235,7 +270,7 @@ private async Task SendAsync( { var statusCode = response.StatusCode; response.Dispose(); - throw new VercelAnalyticsApiException(statusCode); + throw new AnalyticsProviderApiException(statusCode, Definition.Provider); } return response; @@ -243,7 +278,7 @@ private async Task SendAsync( } private static Dictionary BuildVisitParameters( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query) { if (query.Filters?.Any(filter => filter.Dimension == AnalyticsDimension.EventName) is true) @@ -255,7 +290,7 @@ private async Task SendAsync( } private static Dictionary BuildEventParameters( - VercelAnalyticsConnection connection, + AnalyticsConnection connection, AnalyticsQuery query) { var parameters = new Dictionary diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClientRouter.cs b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClientRouter.cs deleted file mode 100644 index 261967f..0000000 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClientRouter.cs +++ /dev/null @@ -1,84 +0,0 @@ -using TheBuilder.WebAnalytics.Configuration; -using TheBuilder.WebAnalytics.Models; - -namespace TheBuilder.WebAnalytics.Services; - -public sealed class VercelAnalyticsClientRouter( - VercelAnalyticsClient vercelClient, - MockVercelAnalyticsClient mockClient) : IVercelAnalyticsClient -{ - public Task GetProjectNameAsync(VercelAnalyticsConnection connection, CancellationToken cancellationToken) => - ClientFor(connection).GetProjectNameAsync(connection, cancellationToken); - - public Task CountAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => - ClientFor(connection).CountAsync(connection, query, cancellationToken); - - public Task GetPageViewTotalAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => - ClientFor(connection).GetPageViewTotalAsync(connection, query, cancellationToken); - - public Task> GetTrendAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => - ClientFor(connection).GetTrendAsync(connection, query, cancellationToken); - - public Task> GetBreakdownAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - AnalyticsDimension dimension, - int limit, - string? search, - CancellationToken cancellationToken) => - ClientFor(connection).GetBreakdownAsync(connection, query, dimension, limit, search, cancellationToken); - - public Task CountEventsAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - string eventName, - AnalyticsEventDataFilter? eventDataFilter, - CancellationToken cancellationToken) => - ClientFor(connection).CountEventsAsync(connection, query, eventName, eventDataFilter, cancellationToken); - - public Task> GetEventsAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - int limit, - string? search, - CancellationToken cancellationToken) => - ClientFor(connection).GetEventsAsync(connection, query, limit, search, cancellationToken); - - public Task> GetFlagsAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - string? flagKey, - int limit, - CancellationToken cancellationToken) => - ClientFor(connection).GetFlagsAsync(connection, query, flagKey, limit, cancellationToken); - - public Task> GetEventPropertyNamesAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - string eventName, - AnalyticsEventDataFilter? eventDataFilter, - CancellationToken cancellationToken) => - ClientFor(connection).GetEventPropertyNamesAsync(connection, query, eventName, eventDataFilter, cancellationToken); - - public Task> GetEventPropertyValuesAsync( - VercelAnalyticsConnection connection, - AnalyticsQuery query, - string eventName, - string propertyName, - int limit, - string? search, - AnalyticsEventDataFilter? eventDataFilter, - CancellationToken cancellationToken) => - ClientFor(connection).GetEventPropertyValuesAsync( - connection, - query, - eventName, - propertyName, - limit, - search, - eventDataFilter, - cancellationToken); - - private IVercelAnalyticsClient ClientFor(VercelAnalyticsConnection connection) => - connection.IsMock ? mockClient : vercelClient; -} diff --git a/src/TheBuilder.WebAnalytics/TheBuilder.WebAnalytics.csproj b/src/TheBuilder.WebAnalytics/TheBuilder.WebAnalytics.csproj index 3b7a3f7..28a6757 100644 --- a/src/TheBuilder.WebAnalytics/TheBuilder.WebAnalytics.csproj +++ b/src/TheBuilder.WebAnalytics/TheBuilder.WebAnalytics.csproj @@ -13,8 +13,8 @@ Web Analytics 0.1.0 Daniel Schmidt - View Vercel Web Analytics directly inside the Umbraco backoffice. - umbraco;umbraco-marketplace;analytics;web analytics;vercel;vercel analytics;traffic;visitors;referrers;utm;dashboard;reporting;content analytics;page analytics;custom events;multi-site + View Vercel and Plausible web analytics directly inside the Umbraco backoffice. + umbraco;umbraco-marketplace;analytics;web analytics;vercel;plausible;traffic;visitors;referrers;utm;dashboard;reporting;content analytics;page analytics;custom events;multi-site https://github.com/thebuilder/web-analytics https://github.com/thebuilder/web-analytics.git git diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsOptionsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs similarity index 69% rename from tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsOptionsValidatorTests.cs rename to tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs index 0633fa2..d8bae45 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsOptionsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs @@ -1,37 +1,38 @@ using Microsoft.Extensions.Options; using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; namespace TheBuilder.WebAnalytics.Tests.Configuration; -public sealed class VercelAnalyticsOptionsValidatorTests +public sealed class WebAnalyticsOptionsValidatorTests { private static readonly Guid MainKey = Guid.Parse("11111111-1111-1111-1111-111111111110"); private static readonly Guid OtherKey = Guid.Parse("22222222-2222-2222-2222-222222222220"); - private readonly VercelAnalyticsOptionsValidator _sut = new(); + private readonly WebAnalyticsOptionsValidator _sut = new(); [Fact] public void Disabled_configuration_is_valid_without_connections() { - Assert.True(_sut.Validate(null, new VercelAnalyticsOptions { Enabled = false }).Succeeded); + Assert.True(_sut.Validate(null, new WebAnalyticsOptions { Enabled = false }).Succeeded); } [Fact] public void Web_analytics_is_enabled_by_default() { - Assert.True(new VercelAnalyticsOptions().Enabled); - Assert.True(new VercelAnalyticsSettings().Enabled); + Assert.True(new WebAnalyticsOptions().Enabled); + Assert.True(new WebAnalyticsSettings().Enabled); } [Fact] public void Enabled_configuration_is_valid_before_ui_setup() { - Assert.True(_sut.Validate(null, new VercelAnalyticsOptions { Enabled = true }).Succeeded); + Assert.True(_sut.Validate(null, new WebAnalyticsOptions { Enabled = true }).Succeeded); } [Fact] public void Shared_token_is_valid_before_ui_setup() { - var options = new VercelAnalyticsOptions + var options = new WebAnalyticsOptions { Enabled = true, Providers = { Vercel = { AccessToken = "server-secret" } } @@ -85,7 +86,7 @@ public void Registry_does_not_throw_when_invalid_keys_bypass_startup_validation( var options = CreateOptions(); options.Connections[0].DocumentRootKeys = ["not-a-guid"]; options.Connections[0].EnabledDocumentTypeKeys = ["also-not-a-guid"]; - var registry = new VercelAnalyticsConnectionRegistry(Options.Create(options)); + var registry = CreateRegistry(options); var connection = registry.Get(MainKey); @@ -100,7 +101,7 @@ public void Registry_uses_shared_access_token_when_connection_has_no_override() var options = CreateOptions(); options.Providers.Vercel.AccessToken = "shared-token"; - var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey); + var connection = CreateRegistry(options).Get(MainKey); Assert.NotNull(connection); Assert.Equal("shared-token", connection.AccessToken); @@ -114,16 +115,47 @@ public void Registry_prefers_connection_access_token_override() options.Providers.Vercel.AccessToken = "shared-token"; options.ConnectionAccessTokens[MainKey.ToString()] = "connection-token"; - var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey); + var connection = CreateRegistry(options).Get(MainKey); Assert.NotNull(connection); Assert.Equal("connection-token", connection.AccessToken); } + [Fact] + public void Registry_resolves_plausible_provider_token() + { + var key = Guid.Parse("99999999-9999-9999-9999-999999999999"); + var options = CreateOptions(); + options.Providers.Plausible.AccessToken = "plausible-token"; + options.Connections = + [ + new AnalyticsConnectionOptions + { + Key = key, + Provider = AnalyticsProvider.Plausible, + SiteId = "example.com", + EventPropertyNames = ["locale", "title"] + } + ]; + + var connection = CreateRegistry(options).Get(key); + + Assert.NotNull(connection); + Assert.Equal(AnalyticsProvider.Plausible, connection.Provider); + Assert.Equal("plausible-token", connection.AccessToken); + Assert.True(connection.IsConfigured); + Assert.False(connection.Capabilities.Flags); + Assert.True(connection.Capabilities.EventProperties); + Assert.True(connection.Capabilities.EventDetails); + Assert.True(connection.Capabilities.Events); + Assert.True(connection.Capabilities.GlobalEventFiltering); + Assert.Equal(["locale", "title"], connection.EventPropertyNames); + } + [Fact] public void Connection_string_representation_redacts_access_token() { - var connection = new VercelAnalyticsConnectionRegistry(Options.Create(CreateOptions())).Get(MainKey); + var connection = CreateRegistry(CreateOptions()).Get(MainKey); var representation = connection!.ToString(); @@ -138,7 +170,7 @@ public void Registry_is_unconfigured_when_shared_and_connection_tokens_are_missi var options = CreateOptions(); options.Providers.Vercel.AccessToken = string.Empty; - var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey); + var connection = CreateRegistry(options).Get(MainKey); Assert.NotNull(connection); Assert.False(connection.HasAccessToken); @@ -150,17 +182,17 @@ public void Registry_reuses_one_snapshot_until_settings_change() { var options = CreateOptions(); var optionsAccessor = Options.Create(options); - var store = new VercelAnalyticsSettingsStore(optionsAccessor); - var registry = new VercelAnalyticsConnectionRegistry(store, optionsAccessor); + var store = new WebAnalyticsSettingsStore(optionsAccessor); + var registry = new AnalyticsConnectionRegistry(store, optionsAccessor); var first = registry.Capture(); var second = registry.Capture(); - store.Save(new VercelAnalyticsSettings + store.Save(new WebAnalyticsSettings { Enabled = true, Connections = [ - new VercelAnalyticsConnectionSettings + new AnalyticsConnectionSettings { Key = MainKey, DisplayName = "Changed", @@ -182,7 +214,7 @@ public void Registry_prefers_nearest_root_order() var options = CreateOptions(); options.Connections.Add(CreateConnection(OtherKey, rootKeys: ["22222222-2222-2222-2222-222222222222"])); - var registry = new VercelAnalyticsConnectionRegistry(Options.Create(options)); + var registry = CreateRegistry(options); var root = registry.FindNearestRoot([ Guid.Parse("22222222-2222-2222-2222-222222222222"), @@ -197,24 +229,30 @@ public void Registry_supports_explicit_and_all_document_type_modes() var options = CreateOptions(); var explicitKey = Guid.Parse("33333333-3333-3333-3333-333333333333"); options.Connections[0].EnabledDocumentTypeKeys = [explicitKey.ToString()]; - var registry = new VercelAnalyticsConnectionRegistry(Options.Create(options)); + var registry = CreateRegistry(options); Assert.True(registry.Get(MainKey)!.IsDocumentTypeEnabled("anything", explicitKey)); Assert.False(registry.Get(MainKey)!.IsDocumentTypeEnabled("anything", Guid.NewGuid())); options.Connections[0].EnableAllDocumentTypes = true; - registry = new VercelAnalyticsConnectionRegistry(Options.Create(options)); + registry = CreateRegistry(options); Assert.True(registry.Get(MainKey)!.IsDocumentTypeEnabled("newPage", Guid.NewGuid())); } - private static VercelAnalyticsOptions CreateOptions() => new() + private static WebAnalyticsOptions CreateOptions() => new() { Enabled = true, Providers = { Vercel = { AccessToken = "test-token" } }, Connections = [CreateConnection(MainKey)] }; - private static VercelAnalyticsConnectionOptions CreateConnection( + private static AnalyticsConnectionRegistry CreateRegistry(WebAnalyticsOptions options) + { + var accessor = Options.Create(options); + return new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(accessor), accessor); + } + + private static AnalyticsConnectionOptions CreateConnection( Guid key, string[]? rootKeys = null) => new() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsSettingsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs similarity index 59% rename from tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsSettingsValidatorTests.cs rename to tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs index d9b1299..4c6b76d 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/VercelAnalyticsSettingsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs @@ -5,7 +5,7 @@ namespace TheBuilder.WebAnalytics.Tests.Configuration; -public sealed class VercelAnalyticsSettingsValidatorTests +public sealed class WebAnalyticsSettingsValidatorTests { private static readonly Guid MainKey = Guid.Parse("11111111-1111-1111-1111-111111111110"); [Fact] @@ -13,7 +13,7 @@ public void Roots_are_optional_for_global_reports() { var settings = CreateSettings(); - Assert.Empty(VercelAnalyticsSettingsValidator.Validate(settings)); + Assert.Empty(WebAnalyticsSettingsValidator.Validate(settings)); } [Fact] @@ -22,7 +22,7 @@ public void Enabled_analytics_does_not_require_a_connection() var settings = CreateSettings(); settings.Connections.Clear(); - Assert.Empty(VercelAnalyticsSettingsValidator.Validate(settings)); + Assert.Empty(WebAnalyticsSettingsValidator.Validate(settings)); } [Fact] @@ -30,7 +30,7 @@ public void Duplicate_root_mappings_across_connections_are_rejected() { var settings = CreateSettings(); settings.Connections[0].DocumentRootKeys = ["11111111-1111-1111-1111-111111111111"]; - settings.Connections.Add(new VercelAnalyticsConnectionSettings + settings.Connections.Add(new AnalyticsConnectionSettings { Key = Guid.Parse("22222222-2222-2222-2222-222222222220"), DisplayName = "Other", @@ -38,7 +38,7 @@ public void Duplicate_root_mappings_across_connections_are_rejected() DocumentRootKeys = ["11111111-1111-1111-1111-111111111111"] }); - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); Assert.Contains(failures, failure => failure.Contains("assigned to both")); } @@ -49,7 +49,7 @@ public void Document_type_keys_must_be_guids() var settings = CreateSettings(); settings.Connections[0].EnabledDocumentTypeKeys = ["not-a-guid"]; - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); Assert.Contains(failures, failure => failure.Contains("invalid document type key")); } @@ -60,7 +60,7 @@ public void All_document_types_does_not_require_explicit_selections() var settings = CreateSettings(); settings.Connections[0].EnableAllDocumentTypes = true; - Assert.Empty(VercelAnalyticsSettingsValidator.Validate(settings)); + Assert.Empty(WebAnalyticsSettingsValidator.Validate(settings)); } [Fact] @@ -70,7 +70,7 @@ public void Mock_scenarios_do_not_require_Vercel_connection_metadata() settings.Connections[0].ProjectId = string.Empty; settings.Connections[0].MockScenario = MockAnalyticsScenario.Flags; - Assert.Empty(VercelAnalyticsSettingsValidator.Validate(settings)); + Assert.Empty(WebAnalyticsSettingsValidator.Validate(settings)); } [Fact] @@ -80,7 +80,7 @@ public void Mock_scenarios_reject_Vercel_connection_metadata() settings.Connections[0].MockScenario = MockAnalyticsScenario.Events; settings.Connections[0].Team = "team_example"; - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); Assert.Contains(failures, failure => failure.Contains("cannot define a Vercel project ID")); Assert.Contains(failures, failure => failure.Contains("cannot define a Vercel team")); @@ -92,7 +92,7 @@ public void Undefined_mock_scenarios_are_rejected_in_persisted_settings() var settings = CreateSettings(); settings.Connections[0].MockScenario = (MockAnalyticsScenario)999; - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); var failure = Assert.Single(failures); Assert.Contains("unsupported mock analytics scenario", failure); @@ -108,11 +108,11 @@ public void Undefined_mock_scenarios_do_not_skip_generic_connection_validation() settings.Connections[0].DocumentRootKeys = ["not-a-guid"]; settings.Connections[0].EnabledDocumentTypeKeys = ["not-a-guid"]; - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); Assert.Contains(failures, failure => failure.Contains("unsupported mock analytics scenario")); Assert.Contains(failures, failure => failure.Contains("requires a valid key")); - Assert.Contains(failures, failure => failure.Contains("requires a project ID")); + Assert.Contains(failures, failure => failure.Contains("requires a Vercel project ID")); Assert.Contains(failures, failure => failure.Contains("invalid document root key")); Assert.Contains(failures, failure => failure.Contains("invalid document type key")); } @@ -123,9 +123,9 @@ public void Undefined_mock_scenarios_are_rejected_in_server_options() var settings = CreateSettings(); settings.Connections[0].MockScenario = (MockAnalyticsScenario)999; - var failures = VercelAnalyticsSettingsValidator.Validate( + var failures = WebAnalyticsSettingsValidator.Validate( settings, - VercelAnalyticsValidationMode.ServerOptions); + WebAnalyticsValidationMode.ServerOptions); var failure = Assert.Single(failures); Assert.Contains("unsupported mock analytics scenario", failure); @@ -142,7 +142,7 @@ public void Defined_mock_scenarios_remain_valid(MockAnalyticsScenario scenario) settings.Connections[0].ProjectId = string.Empty; settings.Connections[0].MockScenario = scenario; - Assert.Empty(VercelAnalyticsSettingsValidator.Validate(settings)); + Assert.Empty(WebAnalyticsSettingsValidator.Validate(settings)); } [Fact] @@ -150,7 +150,7 @@ public void Undefined_mock_scenarios_do_not_stop_other_connections_from_being_va { var settings = CreateSettings(); settings.Connections[0].MockScenario = (MockAnalyticsScenario)999; - settings.Connections.Add(new VercelAnalyticsConnectionSettings + settings.Connections.Add(new AnalyticsConnectionSettings { Key = Guid.Parse("22222222-2222-2222-2222-222222222220"), DisplayName = "Other", @@ -158,7 +158,7 @@ public void Undefined_mock_scenarios_do_not_stop_other_connections_from_being_va EnabledDocumentTypeKeys = ["not-a-guid"] }); - var failures = VercelAnalyticsSettingsValidator.Validate(settings); + var failures = WebAnalyticsSettingsValidator.Validate(settings); Assert.Contains(failures, failure => failure.Contains("unsupported mock analytics scenario")); Assert.Contains(failures, failure => failure.Contains("invalid document type key")); @@ -167,7 +167,7 @@ public void Undefined_mock_scenarios_do_not_stop_other_connections_from_being_va [Fact] public void Store_preserves_mock_identity_without_Vercel_metadata() { - var store = new VercelAnalyticsSettingsStore(Options.Create(new VercelAnalyticsOptions())); + var store = new WebAnalyticsSettingsStore(Options.Create(new WebAnalyticsOptions())); var settings = CreateSettings(); settings.Connections[0].MockScenario = MockAnalyticsScenario.Utm; settings.Connections[0].Team = "team_example"; @@ -184,7 +184,7 @@ public void Store_preserves_mock_identity_without_Vercel_metadata() [Fact] public void Store_normalizes_non_secret_values_without_adding_a_token() { - var store = new VercelAnalyticsSettingsStore(Options.Create(new VercelAnalyticsOptions())); + var store = new WebAnalyticsSettingsStore(Options.Create(new WebAnalyticsOptions())); var settings = CreateSettings(); settings.Connections[0].DocumentRootKeys = ["11111111-1111-1111-1111-111111111111"]; @@ -195,10 +195,59 @@ public void Store_normalizes_non_secret_values_without_adding_a_token() Assert.DoesNotContain("token", System.Text.Json.JsonSerializer.Serialize(connection), StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Store_normalizes_plausible_event_property_names() + { + var store = new WebAnalyticsSettingsStore(Options.Create(new WebAnalyticsOptions())); + var settings = CreateSettings(); + var connection = settings.Connections[0]; + connection.Provider = AnalyticsProvider.Plausible; + connection.ProjectId = string.Empty; + connection.SiteId = "example.com"; + connection.EventPropertyNames = [" locale ", "title", "LOCALE", ""]; + + store.Save(settings); + + Assert.Equal(["locale", "title"], Assert.Single(store.Get().Connections).EventPropertyNames); + } + + [Fact] + public void Event_property_names_are_bounded_and_plausible_only() + { + var settings = CreateSettings(); + settings.Connections[0].EventPropertyNames = ["locale"]; + var vercelFailures = WebAnalyticsSettingsValidator.Validate(settings); + + settings.Connections[0].Provider = AnalyticsProvider.Plausible; + settings.Connections[0].ProjectId = string.Empty; + settings.Connections[0].SiteId = "example.com"; + settings.Connections[0].EventPropertyNames = Enumerable.Range(1, 21).Select(index => $"property-{index}").ToArray(); + var maximumFailures = WebAnalyticsSettingsValidator.Validate(settings); + + Assert.Contains(vercelFailures, failure => failure.Contains("cannot define event properties")); + Assert.Contains(maximumFailures, failure => failure.Contains("cannot define more than 20 event properties")); + } + + [Fact] + public void Null_event_property_names_from_external_json_are_normalized() + { + var settings = CreateSettings(); + var connection = settings.Connections[0]; + connection.Provider = AnalyticsProvider.Plausible; + connection.ProjectId = string.Empty; + connection.SiteId = "example.com"; + connection.EventPropertyNames = null!; + var store = new WebAnalyticsSettingsStore(Options.Create(new WebAnalyticsOptions())); + + store.Save(settings); + + Assert.Empty(Assert.Single(store.Get().Connections).EventPropertyNames); + } + [Fact] public void Store_generates_and_preserves_a_missing_connection_key() { - var store = new VercelAnalyticsSettingsStore(Options.Create(new VercelAnalyticsOptions())); + var store = new WebAnalyticsSettingsStore(Options.Create(new WebAnalyticsOptions())); var settings = CreateSettings(); settings.Connections[0].Key = Guid.Empty; @@ -214,7 +263,7 @@ public void Store_generates_and_preserves_a_missing_connection_key() public void Store_preserves_connection_order_from_server_options() { var secondKey = Guid.Parse("22222222-2222-2222-2222-222222222220"); - var options = new VercelAnalyticsOptions + var options = new WebAnalyticsOptions { Enabled = true, Providers = { Vercel = { AccessToken = "server-secret" } }, @@ -224,7 +273,7 @@ public void Store_preserves_connection_order_from_server_options() new() { Key = secondKey, ProjectId = "second-project" } ] }; - var store = new VercelAnalyticsSettingsStore(Options.Create(options)); + var store = new WebAnalyticsSettingsStore(Options.Create(options)); Assert.Equal([MainKey, secondKey], store.Get().Connections.Select(connection => connection.Key)); } @@ -233,9 +282,9 @@ public void Store_preserves_connection_order_from_server_options() public void Store_observes_settings_saved_by_another_application_node() { var values = new FakeKeyValueService(); - var options = Options.Create(new VercelAnalyticsOptions()); - var firstNode = new VercelAnalyticsSettingsStore(values, options); - var secondNode = new VercelAnalyticsSettingsStore(values, options); + var options = Options.Create(new WebAnalyticsOptions()); + var firstNode = new WebAnalyticsSettingsStore(values, options); + var secondNode = new WebAnalyticsSettingsStore(values, options); var initial = CreateSettings(); firstNode.Save(initial); var secondNodeInitialRevision = secondNode.GetSnapshot().Revision; @@ -251,12 +300,56 @@ public void Store_observes_settings_saved_by_another_application_node() Assert.Equal("Changed on another node", observed.Settings.Connections[0].DisplayName); } - private static VercelAnalyticsSettings CreateSettings() => new() + [Fact] + public void Store_loads_and_roundtrips_legacy_vercel_settings_without_provider_or_credentials() + { + var values = new FakeKeyValueService(); + values.SetValue(StorageKey, LoadFixture("legacy-vercel-settings-v2.json")); + var store = new WebAnalyticsSettingsStore(values, Options.Create(new WebAnalyticsOptions())); + + var loaded = store.Get(); + var connection = Assert.Single(loaded.Connections); + + Assert.True(loaded.Enabled); + Assert.Equal(45, loaded.DefaultRangeDays); + Assert.Equal(TimeSpan.FromMinutes(2), loaded.CacheDuration); + Assert.Equal(MainKey, connection.Key); + Assert.Equal(AnalyticsProvider.Vercel, connection.Provider); + Assert.Equal("Legacy Vercel", connection.DisplayName); + Assert.Equal("prj_legacy", connection.ProjectId); + Assert.Equal("team_legacy", connection.Team); + Assert.Equal(["11111111-1111-1111-1111-111111111111"], connection.DocumentRootKeys); + Assert.True(connection.EnableAllDocumentTypes); + Assert.Equal(["22222222-2222-2222-2222-222222222222"], connection.EnabledDocumentTypeKeys); + Assert.Equal(["articlePage"], connection.EnabledDocumentTypes); + + store.Save(loaded); + var serialized = values.GetValue(StorageKey); + Assert.NotNull(serialized); + Assert.DoesNotContain("token", serialized, StringComparison.OrdinalIgnoreCase); + + var reloaded = new WebAnalyticsSettingsStore(values, Options.Create(new WebAnalyticsOptions())).Get(); + var reloadedConnection = Assert.Single(reloaded.Connections); + Assert.Equal(MainKey, reloadedConnection.Key); + Assert.Equal(AnalyticsProvider.Vercel, reloadedConnection.Provider); + Assert.Equal(connection.ProjectId, reloadedConnection.ProjectId); + Assert.Equal(connection.Team, reloadedConnection.Team); + Assert.Equal(connection.DocumentRootKeys, reloadedConnection.DocumentRootKeys); + Assert.Equal(connection.EnabledDocumentTypeKeys, reloadedConnection.EnabledDocumentTypeKeys); + Assert.Equal(connection.EnabledDocumentTypes, reloadedConnection.EnabledDocumentTypes); + } + + private const string StorageKey = "TheBuilder.WebAnalytics.Settings.v2"; + + private static string LoadFixture(string fileName) => File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName)); + + private static WebAnalyticsSettings CreateSettings() => new() { Enabled = true, Connections = [ - new VercelAnalyticsConnectionSettings + new AnalyticsConnectionSettings { Key = MainKey, DisplayName = "Main", diff --git a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs index db29505..b206115 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs @@ -52,15 +52,15 @@ public async Task Connections_resolves_connection_base_urls_concurrently() var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var started = 0; var routes = new Mock(MockBehavior.Strict); - var projectNames = new Mock(MockBehavior.Strict); + var projectNames = new Mock(MockBehavior.Strict); projectNames .Setup(service => service.GetDisplayNameAsync( - It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync((VercelAnalyticsConnection connection, CancellationToken _) => connection.DisplayName); + .ReturnsAsync((AnalyticsConnection connection, CancellationToken _) => connection.DisplayName); routes .Setup(service => service.GetConnectionBaseUrlAsync( - It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(async () => { @@ -95,17 +95,17 @@ public async Task Mock_connection_without_token_or_document_mappings_is_ready_wi authorization.Setup(service => service.HasAnalyticsSectionAccess()).Returns(true); var routes = new Mock(MockBehavior.Strict); routes.Setup(service => service.GetConnectionBaseUrlAsync( - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync((string?)null); - var projectNames = new Mock(MockBehavior.Strict); + var projectNames = new Mock(MockBehavior.Strict); projectNames.Setup(service => service.GetDisplayNameAsync( - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync("Demo"); - var options = Options.Create(new VercelAnalyticsOptions()); - var store = new VercelAnalyticsSettingsStore(options); - store.Save(new VercelAnalyticsSettings + var options = Options.Create(new WebAnalyticsOptions()); + var store = new WebAnalyticsSettingsStore(options); + store.Save(new WebAnalyticsSettings { Enabled = true, Connections = @@ -118,7 +118,7 @@ public async Task Mock_connection_without_token_or_document_mappings_is_ready_wi } ] }); - var registry = new VercelAnalyticsConnectionRegistry(store, options, mockConnectionsEnabled: true); + var registry = new AnalyticsConnectionRegistry(store, options, mockConnectionsEnabled: true); var controller = new WebAnalyticsApiController( authorization.Object, registry, @@ -170,6 +170,8 @@ public async Task Document_summary_rejects_a_path_not_returned_for_the_document( .ReturnsAsync([ new AnalyticsDocumentRoute( MainKey, + AnalyticsProvider.Vercel, + AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel).Capabilities, "en-US", "example.com", "/published", @@ -245,6 +247,22 @@ public async Task Breakdown_rejects_undefined_numeric_dimension_before_dispatch( AssertInvalidQuery(response.Result); } + [Fact] + public async Task Breakdown_rejects_selectable_ordering_for_vercel() + { + var response = await CreateVercelBoundaryController().Breakdown( + AnalyticsDimension.Country, + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + orderBy: AnalyticsTrafficMetric.PageViews); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("does not support selecting", problem.Detail); + } + [Fact] public async Task Summary_rejects_undefined_numeric_interval_before_dispatch() { @@ -267,7 +285,7 @@ public async Task Summary_rejects_undefined_numeric_interval_before_dispatch() [Fact] public async Task Summary_rejects_event_name_filter_in_visit_scope_before_dispatch() { - var controller = CreateBoundaryOnlyController(); + var controller = CreateVercelBoundaryController(); var response = await controller.Summary( MainKey, @@ -282,13 +300,66 @@ public async Task Summary_rejects_event_name_filter_in_visit_scope_before_dispat AssertInvalidQuery(response.Result); var problem = Assert.IsType(Assert.IsType(response.Result).Value); - Assert.Contains("only supported by the event list report", problem.Detail); + Assert.Contains("does not support event filters for this report", problem.Detail); + } + + [Fact] + public async Task Summary_rejects_filters_not_supported_by_the_selected_provider() + { + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(service => service.HasAnalyticsSectionAccess()).Returns(true); + var controller = new WebAnalyticsApiController( + authorization.Object, + PlausibleRegistry(), + null!, + null!, + null!); + + var response = await controller.Summary( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + null, + null, + null, + ["Route:/articles/[slug]"], + CancellationToken.None); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("does not support Route filters", problem.Detail); + } + + [Fact] + public async Task Breakdown_rejects_a_dimension_not_supported_by_the_selected_provider() + { + var response = await CreatePlausibleBoundaryController().Breakdown( + AnalyticsDimension.Route, + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day); + + AssertInvalidQuery(response.Result); + } + + [Fact] + public async Task Flags_rejects_a_provider_without_flag_capabilities() + { + var response = await CreatePlausibleBoundaryController().Flags( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day); + + AssertInvalidQuery(response.Result); } [Fact] public async Task Event_details_rejects_event_name_filter_from_the_shared_query() { - var controller = CreateBoundaryOnlyController(); + var controller = CreateVercelBoundaryController(); var response = await controller.EventDetails( MainKey, @@ -304,7 +375,7 @@ public async Task Event_details_rejects_event_name_filter_from_the_shared_query( [Fact] public async Task Event_property_values_reject_event_name_filter_from_the_shared_query() { - var controller = CreateBoundaryOnlyController(); + var controller = CreateVercelBoundaryController(); var response = await controller.EventPropertyValues( MainKey, @@ -321,16 +392,30 @@ public async Task Event_property_values_reject_event_name_filter_from_the_shared private static WebAnalyticsApiController CreateBoundaryOnlyController() => new(null!, null!, null!, null!, null!); + private static WebAnalyticsApiController CreateVercelBoundaryController() + { + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(service => service.HasAnalyticsSectionAccess()).Returns(true); + return new(authorization.Object, EnabledRegistry(), null!, null!, null!); + } + + private static WebAnalyticsApiController CreatePlausibleBoundaryController() + { + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(service => service.HasAnalyticsSectionAccess()).Returns(true); + return new(authorization.Object, PlausibleRegistry(), null!, null!, null!); + } + private static DateTimeOffset UtcDate(int year, int month, int day) => new(year, month, day, 0, 0, 0, TimeSpan.Zero); - private static VercelAnalyticsConnectionRegistry EnabledRegistry(params string[] aliases) => - new(Options.Create(new VercelAnalyticsOptions + private static AnalyticsConnectionRegistry EnabledRegistry(params string[] aliases) => + CreateRegistry(new WebAnalyticsOptions { Enabled = true, Providers = { Vercel = { AccessToken = "secret" } }, Connections = (aliases.Length == 0 ? ["main"] : aliases).Select( - alias => new VercelAnalyticsConnectionOptions + alias => new AnalyticsConnectionOptions { Key = alias == "secondary" ? SecondaryKey : MainKey, DisplayName = alias, @@ -338,13 +423,37 @@ private static VercelAnalyticsConnectionRegistry EnabledRegistry(params string[] DocumentRootKeys = [Guid.NewGuid().ToString()], EnableAllDocumentTypes = true }).ToList() - })); + }); + + private static AnalyticsConnectionRegistry PlausibleRegistry() => + CreateRegistry(new WebAnalyticsOptions + { + Enabled = true, + Providers = { Plausible = { AccessToken = "secret" } }, + Connections = + [ + new() + { + Key = MainKey, + Provider = AnalyticsProvider.Plausible, + DisplayName = "Plausible", + SiteId = "example.com", + EnableAllDocumentTypes = true + } + ] + }); + + private static AnalyticsConnectionRegistry CreateRegistry(WebAnalyticsOptions options) + { + var accessor = Options.Create(options); + return new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(accessor), accessor); + } private static void AssertInvalidQuery(ActionResult? result) { var objectResult = Assert.IsType(result); Assert.Equal(StatusCodes.Status400BadRequest, objectResult.StatusCode); var problem = Assert.IsType(objectResult.Value); - Assert.Equal(VercelAnalyticsProblemCodes.InvalidQuery, problem.Code); + Assert.Equal(WebAnalyticsProblemCodes.InvalidQuery, problem.Code); } } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Controllers/VercelAnalyticsProblemFactoryTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsProblemFactoryTests.cs similarity index 55% rename from tests/TheBuilder.WebAnalytics.Tests/Controllers/VercelAnalyticsProblemFactoryTests.cs rename to tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsProblemFactoryTests.cs index b941d40..768aa06 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Controllers/VercelAnalyticsProblemFactoryTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsProblemFactoryTests.cs @@ -11,69 +11,81 @@ namespace TheBuilder.WebAnalytics.Tests.Controllers; -public sealed class VercelAnalyticsProblemFactoryTests +public sealed class WebAnalyticsProblemFactoryTests { [Fact] public void Maps_credential_failures_to_stable_problem_code() { - var problem = Assert.IsType( - VercelAnalyticsProblemFactory.FromException( - new VercelAnalyticsApiException(HttpStatusCode.Forbidden))); + var problem = Assert.IsType( + WebAnalyticsProblemFactory.FromException( + new AnalyticsProviderApiException(HttpStatusCode.Forbidden, AnalyticsProvider.Vercel))); Assert.Equal(StatusCodes.Status502BadGateway, problem.Status); - Assert.Equal(VercelAnalyticsProblemCodes.InvalidCredentials, problem.Code); + Assert.Equal(WebAnalyticsProblemCodes.InvalidCredentials, problem.Code); } [Theory] - [InlineData(HttpStatusCode.PaymentRequired, StatusCodes.Status402PaymentRequired, VercelAnalyticsProblemCodes.PlanLimit)] - [InlineData(HttpStatusCode.BadRequest, StatusCodes.Status400BadRequest, VercelAnalyticsProblemCodes.InvalidQuery)] - [InlineData(HttpStatusCode.ServiceUnavailable, StatusCodes.Status502BadGateway, VercelAnalyticsProblemCodes.UpstreamUnavailable)] + [InlineData(HttpStatusCode.PaymentRequired, StatusCodes.Status402PaymentRequired, WebAnalyticsProblemCodes.PlanLimit)] + [InlineData(HttpStatusCode.BadRequest, StatusCodes.Status400BadRequest, WebAnalyticsProblemCodes.InvalidQuery)] + [InlineData(HttpStatusCode.ServiceUnavailable, StatusCodes.Status502BadGateway, WebAnalyticsProblemCodes.UpstreamUnavailable)] public void Maps_vercel_failures_to_stable_problem_codes( HttpStatusCode upstreamStatus, int expectedStatus, string expectedCode) { - var problem = Assert.IsType( - VercelAnalyticsProblemFactory.FromException(new VercelAnalyticsApiException(upstreamStatus))); + var problem = Assert.IsType( + WebAnalyticsProblemFactory.FromException(new AnalyticsProviderApiException(upstreamStatus, AnalyticsProvider.Vercel))); Assert.Equal(expectedStatus, problem.Status); Assert.Equal(expectedCode, problem.Code); } + [Fact] + public void Maps_plausible_not_found_to_invalid_query() + { + var problem = Assert.IsType( + WebAnalyticsProblemFactory.FromException(new AnalyticsProviderApiException( + HttpStatusCode.NotFound, + AnalyticsProvider.Plausible))); + + Assert.Equal(StatusCodes.Status400BadRequest, problem.Status); + Assert.Equal(WebAnalyticsProblemCodes.InvalidQuery, problem.Code); + } + [Fact] public void Maps_transport_timeout_and_payload_failures() { Assert.Equal( - VercelAnalyticsProblemCodes.UpstreamTransport, - VercelAnalyticsProblemFactory.FromException(new HttpRequestException())?.Code); + WebAnalyticsProblemCodes.UpstreamTransport, + WebAnalyticsProblemFactory.FromException(new HttpRequestException())?.Code); Assert.Equal( - VercelAnalyticsProblemCodes.UpstreamTimeout, - VercelAnalyticsProblemFactory.FromException(new TaskCanceledException())?.Code); + WebAnalyticsProblemCodes.UpstreamTimeout, + WebAnalyticsProblemFactory.FromException(new TaskCanceledException())?.Code); Assert.Equal( - VercelAnalyticsProblemCodes.InvalidUpstreamPayload, - VercelAnalyticsProblemFactory.FromException(new JsonException())?.Code); + WebAnalyticsProblemCodes.InvalidUpstreamPayload, + WebAnalyticsProblemFactory.FromException(new JsonException())?.Code); } [Fact] public void Maps_report_capacity_failures_to_service_unavailable() { - var problem = Assert.IsType( - VercelAnalyticsProblemFactory.FromException(new AnalyticsReportCapacityException())); + var problem = Assert.IsType( + WebAnalyticsProblemFactory.FromException(new AnalyticsReportCapacityException())); Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.Status); - Assert.Equal(VercelAnalyticsProblemCodes.ReportCapacity, problem.Code); + Assert.Equal(WebAnalyticsProblemCodes.ReportCapacity, problem.Code); } [Fact] public void Returns_a_typed_problem_details_contract() { - var result = VercelAnalyticsProblemFactory.CreateResult( + var result = WebAnalyticsProblemFactory.CreateResult( StatusCodes.Status400BadRequest, - VercelAnalyticsProblemCodes.InvalidQuery, + WebAnalyticsProblemCodes.InvalidQuery, "Invalid analytics query."); var details = Assert.IsType(result.Value); - Assert.Equal(VercelAnalyticsProblemCodes.InvalidQuery, details.Code); + Assert.Equal(WebAnalyticsProblemCodes.InvalidQuery, details.Code); } [Fact] @@ -81,9 +93,9 @@ public void Exception_filter_maps_transport_failures_at_the_controller_boundary( { var context = CreateExceptionContext(new HttpRequestException()); - new VercelAnalyticsExceptionFilter().OnException(context); + new WebAnalyticsExceptionFilter().OnException(context); - AssertProblem(context, StatusCodes.Status502BadGateway, VercelAnalyticsProblemCodes.UpstreamTransport); + AssertProblem(context, StatusCodes.Status502BadGateway, WebAnalyticsProblemCodes.UpstreamTransport); } [Fact] @@ -91,9 +103,9 @@ public void Exception_filter_maps_invalid_json_at_the_controller_boundary() { var context = CreateExceptionContext(new JsonException()); - new VercelAnalyticsExceptionFilter().OnException(context); + new WebAnalyticsExceptionFilter().OnException(context); - AssertProblem(context, StatusCodes.Status502BadGateway, VercelAnalyticsProblemCodes.InvalidUpstreamPayload); + AssertProblem(context, StatusCodes.Status502BadGateway, WebAnalyticsProblemCodes.InvalidUpstreamPayload); } private static ExceptionContext CreateExceptionContext(Exception exception) diff --git a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs index e7a8cb8..f3dad8f 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using System.Text.Json; using Moq; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; @@ -20,9 +21,12 @@ public sealed class WebAnalyticsSettingsApiControllerTests [InlineData(false)] public async Task Settings_preserve_mock_identity_and_report_runtime_availability(bool mockConnectionsEnabled) { - var options = Options.Create(new VercelAnalyticsOptions()); - var store = new VercelAnalyticsSettingsStore(options); - store.Save(new VercelAnalyticsSettings + var options = Options.Create(new WebAnalyticsOptions + { + Providers = { Vercel = { AccessToken = "server-secret" } } + }); + var store = new WebAnalyticsSettingsStore(options); + store.Save(new WebAnalyticsSettings { Enabled = true, Connections = @@ -35,14 +39,14 @@ public async Task Settings_preserve_mock_identity_and_report_runtime_availabilit } ] }); - var registry = new VercelAnalyticsConnectionRegistry(store, options, mockConnectionsEnabled); + var registry = new AnalyticsConnectionRegistry(store, options, mockConnectionsEnabled); var controller = new WebAnalyticsSettingsApiController( CreateAdministratorSecurityAccessor(), store, registry, options, - Mock.Of(MockBehavior.Strict), - Mock.Of(MockBehavior.Strict)); + new TestAnalyticsProviderClientResolver(Mock.Of(MockBehavior.Strict)), + Mock.Of(MockBehavior.Strict)); var result = await controller.Settings(CancellationToken.None); @@ -50,34 +54,46 @@ public async Task Settings_preserve_mock_identity_and_report_runtime_availabilit var connection = Assert.Single(response.Connections); Assert.Equal(MockAnalyticsScenario.Flags, connection.MockScenario); Assert.Equal(mockConnectionsEnabled, response.CanCreateMockConnections); + var vercel = Assert.Single(response.Providers, provider => provider.Provider == AnalyticsProvider.Vercel); + Assert.Equal("projectId", vercel.Identifier.Key); + Assert.NotNull(vercel.Team); + Assert.Null(vercel.EventProperties); + var plausible = Assert.Single(response.Providers, provider => provider.Provider == AnalyticsProvider.Plausible); + Assert.Equal("siteId", plausible.Identifier.Key); + Assert.Null(plausible.Team); + Assert.Equal(20, plausible.EventProperties?.MaximumNames); + Assert.DoesNotContain("server-secret", JsonSerializer.Serialize(response), StringComparison.Ordinal); } [Fact] public async Task Save_settings_returns_bad_request_for_an_undefined_mock_scenario() { - var options = Options.Create(new VercelAnalyticsOptions()); - var store = new VercelAnalyticsSettingsStore(options); + var options = Options.Create(new WebAnalyticsOptions()); + var store = new WebAnalyticsSettingsStore(options); var controller = new WebAnalyticsSettingsApiController( CreateAdministratorSecurityAccessor(), store, - new VercelAnalyticsConnectionRegistry(store, options, mockConnectionsEnabled: true), + new AnalyticsConnectionRegistry(store, options, mockConnectionsEnabled: true), options, - Mock.Of(MockBehavior.Strict), - Mock.Of(MockBehavior.Strict)); + new TestAnalyticsProviderClientResolver(Mock.Of(MockBehavior.Strict)), + Mock.Of(MockBehavior.Strict)); var request = new UpdateAnalyticsSettingsRequest( true, 30, "00:05:00", [ - new UpdateAnalyticsConnectionRequest( - MockKey, - "Unknown mock", - string.Empty, - null, - (MockAnalyticsScenario)999, - [], - false, - []) + new UpdateAnalyticsConnectionRequest + { + Key = MockKey, + DisplayName = "Unknown mock", + Provider = AnalyticsProvider.Vercel, + ProjectId = string.Empty, + SiteId = string.Empty, + MockScenario = (MockAnalyticsScenario)999, + DocumentRootKeys = [], + EnableAllDocumentTypes = false, + EnabledDocumentTypeKeys = [] + } ]); var result = await controller.SaveSettings(request, CancellationToken.None); @@ -88,6 +104,47 @@ public async Task Save_settings_returns_bad_request_for_an_undefined_mock_scenar Assert.Contains("unsupported mock analytics scenario", problem.Detail); } + [Fact] + public async Task Save_settings_rejects_changing_provider_for_an_existing_connection() + { + var options = Options.Create(new WebAnalyticsOptions()); + var store = new WebAnalyticsSettingsStore(options); + store.Save(new WebAnalyticsSettings + { + Connections = [new() { Key = MockKey, Provider = AnalyticsProvider.Vercel, ProjectId = "prj_example" }] + }); + var controller = new WebAnalyticsSettingsApiController( + CreateAdministratorSecurityAccessor(), + store, + new AnalyticsConnectionRegistry(store, options, mockConnectionsEnabled: false), + options, + new TestAnalyticsProviderClientResolver(Mock.Of(MockBehavior.Strict)), + Mock.Of(MockBehavior.Strict)); + var request = new UpdateAnalyticsSettingsRequest( + true, + 30, + "00:05:00", + [ + new() + { + Key = MockKey, + DisplayName = "Example", + Provider = AnalyticsProvider.Plausible, + ProjectId = string.Empty, + SiteId = "example.com", + DocumentRootKeys = [], + EnableAllDocumentTypes = false, + EnabledDocumentTypeKeys = [] + } + ]); + + var result = await controller.SaveSettings(request, CancellationToken.None); + + var response = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status400BadRequest, response.StatusCode); + Assert.Contains("cannot change analytics provider", Assert.IsType(response.Value).Detail); + } + private static IBackOfficeSecurityAccessor CreateAdministratorSecurityAccessor() { var administratorGroup = new Mock(); diff --git a/tests/TheBuilder.WebAnalytics.Tests/Fixtures/legacy-vercel-settings-v2.json b/tests/TheBuilder.WebAnalytics.Tests/Fixtures/legacy-vercel-settings-v2.json new file mode 100644 index 0000000..e49a442 --- /dev/null +++ b/tests/TheBuilder.WebAnalytics.Tests/Fixtures/legacy-vercel-settings-v2.json @@ -0,0 +1,17 @@ +{ + "enabled": true, + "defaultRangeDays": 45, + "cacheDuration": "00:02:00", + "connections": [ + { + "key": "11111111-1111-1111-1111-111111111110", + "displayName": "Legacy Vercel", + "projectId": "prj_legacy", + "team": "team_legacy", + "documentRootKeys": ["11111111-1111-1111-1111-111111111111"], + "enableAllDocumentTypes": true, + "enabledDocumentTypeKeys": ["22222222-2222-2222-2222-222222222222"], + "enabledDocumentTypes": ["articlePage"] + } + ] +} diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelProjectNameServiceTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsConnectionNameServiceTests.cs similarity index 62% rename from tests/TheBuilder.WebAnalytics.Tests/Services/VercelProjectNameServiceTests.cs rename to tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsConnectionNameServiceTests.cs index 701a085..2ccb1c0 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelProjectNameServiceTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsConnectionNameServiceTests.cs @@ -6,33 +6,33 @@ namespace TheBuilder.WebAnalytics.Tests.Services; -public sealed class VercelProjectNameServiceTests +public sealed class AnalyticsConnectionNameServiceTests { [Fact] public async Task Resolves_and_caches_the_vercel_project_name() { - var client = new Mock(MockBehavior.Strict); + var client = new Mock(MockBehavior.Strict); var connection = CreateConnection(); - client.Setup(item => item.GetProjectNameAsync(connection, It.IsAny())) + client.Setup(item => item.GetDisplayNameAsync(connection, It.IsAny())) .ReturnsAsync("health-platform"); - var service = new VercelProjectNameService(client.Object, new MemoryCache(new MemoryCacheOptions())); + var service = CreateService(client.Object); var first = await service.GetDisplayNameAsync(connection, CancellationToken.None); var second = await service.GetDisplayNameAsync(connection, CancellationToken.None); Assert.Equal("health-platform", first); Assert.Equal(first, second); - client.Verify(item => item.GetProjectNameAsync(connection, It.IsAny()), Times.Once); + client.Verify(item => item.GetDisplayNameAsync(connection, It.IsAny()), Times.Once); } [Fact] public async Task Falls_back_to_project_id_when_metadata_is_unavailable() { - var client = new Mock(MockBehavior.Strict); + var client = new Mock(MockBehavior.Strict); var connection = CreateConnection(); - client.Setup(item => item.GetProjectNameAsync(connection, It.IsAny())) - .ThrowsAsync(new VercelAnalyticsApiException(System.Net.HttpStatusCode.Forbidden)); - var service = new VercelProjectNameService(client.Object, new MemoryCache(new MemoryCacheOptions())); + client.Setup(item => item.GetDisplayNameAsync(connection, It.IsAny())) + .ThrowsAsync(new AnalyticsProviderApiException(System.Net.HttpStatusCode.Forbidden, AnalyticsProvider.Vercel)); + var service = CreateService(client.Object); var result = await service.GetDisplayNameAsync(connection, CancellationToken.None); @@ -42,12 +42,12 @@ public async Task Falls_back_to_project_id_when_metadata_is_unavailable() [Fact] public async Task Caches_project_names_per_connection_identity() { - var client = new Mock(MockBehavior.Strict); - client.Setup(item => item.GetProjectNameAsync( - It.IsAny(), + var client = new Mock(MockBehavior.Strict); + client.Setup(item => item.GetDisplayNameAsync( + It.IsAny(), It.IsAny())) - .ReturnsAsync((VercelAnalyticsConnection connection, CancellationToken _) => connection.DisplayName); - var service = new VercelProjectNameService(client.Object, new MemoryCache(new MemoryCacheOptions())); + .ReturnsAsync((AnalyticsConnection connection, CancellationToken _) => connection.DisplayName); + var service = CreateService(client.Object); var flags = CreateConnection() with { Key = Guid.Parse("22222222-2222-2222-2222-222222222220"), @@ -69,19 +69,25 @@ public async Task Caches_project_names_per_connection_identity() Assert.Equal("Mock · Feature flags", flagName); Assert.Equal("Mock · Custom events", eventName); - client.Verify(item => item.GetProjectNameAsync( - It.IsAny(), + client.Verify(item => item.GetDisplayNameAsync( + It.IsAny(), It.IsAny()), Times.Exactly(2)); } - private static VercelAnalyticsConnection CreateConnection() => new( + private static AnalyticsConnection CreateConnection() => new( Guid.Parse("11111111-1111-1111-1111-111111111110"), "Old display name", + AnalyticsProvider.Vercel, "secret", "project", "team_123", + string.Empty, + [], [], true, new HashSet(), new HashSet()); + + private static AnalyticsConnectionNameService CreateService(IAnalyticsProviderClient client) => + new(new TestAnalyticsProviderClientResolver(client), new MemoryCache(new MemoryCacheOptions())); } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs index a7e1df4..22de874 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsDocumentRouteServiceTests.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; using TheBuilder.WebAnalytics.Services; namespace TheBuilder.WebAnalytics.Tests.Services; @@ -137,6 +138,42 @@ public async Task Document_root_provides_base_url() Assert.Equal("https://root.example", baseUrl); } + [Fact] + public async Task Plausible_site_id_provides_base_url_without_a_document_root() + { + var connection = Connection("plausible"); + connection.Provider = AnalyticsProvider.Plausible; + connection.ProjectId = string.Empty; + connection.SiteId = "charlietango.dk"; + var registry = CreateRegistry(connection); + var service = new AnalyticsDocumentRouteService( + Mock.Of(), + Mock.Of(), + registry); + + var baseUrl = await service.GetConnectionBaseUrlAsync( + registry.Get(SiteConnectionKey)!, + CancellationToken.None); + + Assert.Equal("https://charlietango.dk", baseUrl); + } + + [Fact] + public async Task Provider_without_a_fallback_and_document_root_has_no_base_url() + { + var registry = CreateRegistry(Connection("site")); + var service = new AnalyticsDocumentRouteService( + Mock.Of(), + Mock.Of(), + registry); + + var baseUrl = await service.GetConnectionBaseUrlAsync( + registry.Get(SiteConnectionKey)!, + CancellationToken.None); + + Assert.Null(baseUrl); + } + private static Mock CreateContentTree(Guid rootKey, Guid documentKey) { var root = new Mock(); @@ -161,7 +198,7 @@ private static Mock CreateContentTree(Guid rootKey, Guid docume Guid.NewGuid(), [new AnalyticsPublishedRoute(culture, hostname, path, $"https://{hostname}{path}")]); - private static VercelAnalyticsConnectionOptions Connection( + private static AnalyticsConnectionOptions Connection( string alias, IReadOnlyList? roots = null, IReadOnlyList? documentTypes = null) => new() @@ -173,13 +210,20 @@ private static VercelAnalyticsConnectionOptions Connection( EnabledDocumentTypes = documentTypes?.ToArray() ?? ["articlePage"] }; - private static VercelAnalyticsConnectionRegistry CreateRegistry( - params VercelAnalyticsConnectionOptions[] connections) => new(Options.Create(new VercelAnalyticsOptions + private static AnalyticsConnectionRegistry CreateRegistry(params AnalyticsConnectionOptions[] connections) + { + var options = Options.Create(new WebAnalyticsOptions { Enabled = true, - Providers = { Vercel = { AccessToken = "secret" } }, + Providers = + { + Vercel = { AccessToken = "secret" }, + Plausible = { AccessToken = "plausible-secret" } + }, Connections = connections.ToList() - })); + }); + return new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(options), options); + } private static Guid KeyFor(string alias) => alias == "root" ? RootConnectionKey : SiteConnectionKey; } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsReportServiceTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs similarity index 75% rename from tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsReportServiceTests.cs rename to tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs index 5f82610..33fa6e8 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsReportServiceTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs @@ -5,7 +5,7 @@ namespace TheBuilder.WebAnalytics.Tests.Services; -public sealed class VercelAnalyticsReportServiceTests +public sealed class AnalyticsReportServiceTests { private static readonly Guid MainKey = Guid.Parse("11111111-1111-1111-1111-111111111110"); @@ -14,7 +14,7 @@ public async Task Summary_is_cached_by_normalized_query() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var query = CreateQuery(); var first = await service.GetSummaryAsync(query, CancellationToken.None); @@ -24,7 +24,6 @@ public async Task Summary_is_cached_by_normalized_query() Assert.Same(first, second); Assert.Equal(2, client.CountCalls); Assert.Equal(1, client.TrendCalls); - Assert.Equal(2, client.PageViewTotalCalls); } [Fact] @@ -32,7 +31,7 @@ public async Task Zero_cache_duration_disables_caching() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService( + var service = CreateService( CreateRegistry(cacheDuration: TimeSpan.Zero), client, cache); @@ -43,7 +42,6 @@ public async Task Zero_cache_duration_disables_caching() Assert.Equal(4, client.CountCalls); Assert.Equal(2, client.TrendCalls); - Assert.Equal(4, client.PageViewTotalCalls); } [Fact] @@ -51,7 +49,7 @@ public async Task Summary_compares_with_the_immediately_preceding_range() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var summary = await service.GetSummaryAsync(CreateQuery(), CancellationToken.None); @@ -67,7 +65,7 @@ public async Task Summary_remains_available_when_the_previous_range_is_unavailab { var client = new CountingClient { FailPreviousCount = true }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var summary = await service.GetSummaryAsync(CreateQuery(), CancellationToken.None); @@ -78,7 +76,7 @@ public async Task Summary_remains_available_when_the_previous_range_is_unavailab public static TheoryData> OptionalPreviousRangeFailures => [ - () => new VercelAnalyticsApiException(System.Net.HttpStatusCode.PaymentRequired), + () => new AnalyticsProviderApiException(System.Net.HttpStatusCode.PaymentRequired, AnalyticsProvider.Vercel), () => new HttpRequestException(), () => new System.Text.Json.JsonException(), () => new OperationCanceledException() @@ -91,7 +89,7 @@ public async Task Summary_remains_available_when_an_optional_previous_range_requ { var client = new CountingClient { PreviousCountException = createException() }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var summary = await service.GetSummaryAsync(CreateQuery(), CancellationToken.None); @@ -111,7 +109,7 @@ public async Task Summary_propagates_caller_cancellation_during_an_optional_prev BeforePreviousCountFailure = cancellation.Cancel }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await Assert.ThrowsAnyAsync(() => service.GetSummaryAsync(CreateQuery(), cancellation.Token)); @@ -130,7 +128,7 @@ public async Task Cancelling_one_summary_waiter_during_an_optional_previous_rang PreviousCountRelease = releasePreviousCount }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var first = service.GetSummaryAsync(CreateQuery(), firstCancellation.Token); await previousCountStarted.Task; @@ -153,7 +151,7 @@ public async Task Summary_propagates_malformed_current_range_data() { var client = new CountingClient { CurrentCountException = new System.Text.Json.JsonException() }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await Assert.ThrowsAsync(() => service.GetSummaryAsync(CreateQuery(), CancellationToken.None)); @@ -164,37 +162,7 @@ public async Task Summary_propagates_unexpected_previous_range_failures() { var client = new CountingClient { PreviousCountException = new InvalidOperationException() }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); - - await Assert.ThrowsAsync(() => - service.GetSummaryAsync(CreateQuery(), CancellationToken.None)); - } - - [Fact] - public async Task Summary_propagates_an_unexpected_previous_page_view_failure_when_count_has_an_optional_failure() - { - var client = new CountingClient - { - PreviousCountException = new VercelAnalyticsApiException(System.Net.HttpStatusCode.PaymentRequired), - PreviousPageViewTotalException = new InvalidOperationException() - }; - using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); - - await Assert.ThrowsAsync(() => - service.GetSummaryAsync(CreateQuery(), CancellationToken.None)); - } - - [Fact] - public async Task Summary_propagates_an_unexpected_previous_count_failure_when_page_views_have_an_optional_failure() - { - var client = new CountingClient - { - PreviousCountException = new InvalidOperationException(), - PreviousPageViewTotalException = new VercelAnalyticsApiException(System.Net.HttpStatusCode.PaymentRequired) - }; - using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await Assert.ThrowsAsync(() => service.GetSummaryAsync(CreateQuery(), CancellationToken.None)); @@ -205,7 +173,7 @@ public async Task Summary_omits_comparison_when_the_previous_range_would_precede { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var minimumDate = DateTimeOffset.MinValue; var summary = await service.GetSummaryAsync( @@ -223,7 +191,7 @@ public async Task Breakdown_cache_separates_dimensions() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await service.GetBreakdownAsync(CreateQuery(), AnalyticsDimension.Country, 10, null, CancellationToken.None); await service.GetBreakdownAsync(CreateQuery(), AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); @@ -236,7 +204,7 @@ public async Task Breakdown_cache_separates_search_terms() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await service.GetBreakdownAsync(CreateQuery(), AnalyticsDimension.RequestPath, 100, "news", CancellationToken.None); await service.GetBreakdownAsync(CreateQuery(), AnalyticsDimension.RequestPath, 100, "about", CancellationToken.None); @@ -249,7 +217,7 @@ public async Task Summary_cache_separates_filter_values() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); await service.GetSummaryAsync(CreateQuery() with { @@ -262,7 +230,6 @@ await service.GetSummaryAsync(CreateQuery() with Assert.Equal(4, client.CountCalls); Assert.Equal(2, client.TrendCalls); - Assert.Equal(4, client.PageViewTotalCalls); } [Fact] @@ -270,7 +237,7 @@ public async Task Events_are_cached_by_search_and_document_scope() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var query = CreateQuery() with { RequestPath = "/news" }; await service.GetEventsAsync(query, 100, "signup", CancellationToken.None); @@ -281,11 +248,11 @@ public async Task Events_are_cached_by_search_and_document_scope() } [Fact] - public async Task Event_details_return_property_names_without_eagerly_loading_values() + public async Task Event_details_reuse_discovered_property_values() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var first = await service.GetEventDetailsAsync(CreateQuery(), "Signup", null, CancellationToken.None); var second = await service.GetEventDetailsAsync(CreateQuery(), "Signup", null, CancellationToken.None); @@ -295,18 +262,34 @@ public async Task Event_details_return_property_names_without_eagerly_loading_va Assert.Equal(new AnalyticsEventTotals(30, 12), first.Totals); var property = Assert.Single(first.Properties); Assert.Equal("plan", property.Name); - Assert.Empty(property.Values); + Assert.Equal(new AnalyticsEventPropertyValue("Pro", 20, 10), Assert.Single(property.Values)); Assert.Equal(1, client.EventCountCalls); Assert.Equal(1, client.EventPropertyNameCalls); Assert.Equal(0, client.EventPropertyValueCalls); } + [Fact] + public async Task Event_property_discovery_is_cached_across_event_details() + { + var client = new CountingClient(); + using var cache = new AnalyticsReportCache(); + var service = CreateService(CreateRegistry(), client, cache); + + var signup = await service.GetEventDetailsAsync(CreateQuery(), "Signup", null, CancellationToken.None); + var purchase = await service.GetEventDetailsAsync(CreateQuery(), "Purchase", null, CancellationToken.None); + + Assert.Equal("plan", Assert.Single(signup!.Properties).Name); + Assert.Empty(purchase!.Properties); + Assert.Equal(1, client.EventPropertyNameCalls); + Assert.Equal(2, client.EventCountCalls); + } + [Fact] public async Task Event_details_cache_and_queries_include_the_event_data_filter() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var filter = new AnalyticsEventDataFilter("plan", "Enterprise"); await service.GetEventDetailsAsync(CreateQuery(), "Signup", filter, CancellationToken.None); @@ -322,7 +305,7 @@ public async Task Event_details_cache_distinguishes_event_names_from_filter_segm { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var filter = new AnalyticsEventDataFilter("plan", "Pro"); await service.GetEventDetailsAsync(CreateQuery(), "Signup:cGxhbg==:UHJv", null, CancellationToken.None); @@ -339,7 +322,7 @@ public async Task Event_property_search_is_server_backed_and_cached_by_search_te { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var first = await service.GetEventPropertyValuesAsync(CreateQuery(), "Signup", "plan", 100, "enterprise", null, CancellationToken.None); var second = await service.GetEventPropertyValuesAsync(CreateQuery(), "Signup", "plan", 100, "enterprise", null, CancellationToken.None); @@ -357,7 +340,7 @@ public async Task Already_cancelled_summary_does_not_call_the_Vercel_client() { var client = new CountingClient(); using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); @@ -365,7 +348,6 @@ await Assert.ThrowsAnyAsync(() => service.GetSummaryAsync(CreateQuery(), cancellation.Token)); Assert.Equal(0, client.CountCalls); - Assert.Equal(0, client.PageViewTotalCalls); Assert.Equal(0, client.TrendCalls); } @@ -376,7 +358,7 @@ public async Task Concurrent_identical_summaries_share_one_client_fanout() var releaseCounts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var client = new CountingClient { CountStarted = countStarted, CountRelease = releaseCounts }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); var first = service.GetSummaryAsync(CreateQuery(), CancellationToken.None); await countStarted.Task; @@ -388,7 +370,6 @@ public async Task Concurrent_identical_summaries_share_one_client_fanout() Assert.NotNull(summaries[0]); Assert.Same(summaries[0], summaries[1]); Assert.Equal(2, client.CountCalls); - Assert.Equal(2, client.PageViewTotalCalls); Assert.Equal(1, client.TrendCalls); } @@ -399,7 +380,7 @@ public async Task Cancelling_one_summary_waiter_does_not_cancel_the_shared_clien var releaseCounts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var client = new CountingClient { CountStarted = countStarted, CountRelease = releaseCounts }; using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService(CreateRegistry(), client, cache); + var service = CreateService(CreateRegistry(), client, cache); using var firstCancellation = new CancellationTokenSource(); var first = service.GetSummaryAsync(CreateQuery(), firstCancellation.Token); @@ -421,29 +402,44 @@ public async Task Cancelling_one_summary_waiter_does_not_cancel_the_shared_clien new DateOnly(2026, 7, 15), AnalyticsInterval.Day); - private static VercelAnalyticsConnectionRegistry CreateRegistry(TimeSpan? cacheDuration = null) => new(Options.Create(new VercelAnalyticsOptions + private static AnalyticsReportService CreateService( + AnalyticsConnectionRegistry registry, + IAnalyticsProviderClient client, + AnalyticsReportCache cache) => + new(registry, new TestAnalyticsProviderClientResolver(client), cache); + + private static AnalyticsConnectionRegistry CreateRegistry(TimeSpan? cacheDuration = null) { - Enabled = true, - Providers = { Vercel = { AccessToken = "secret" } }, - CacheDuration = cacheDuration ?? TimeSpan.FromMinutes(5), - Connections = - [ - new() - { - Key = MainKey, - DisplayName = "Main", - ProjectId = "project", - DocumentRootKeys = [Guid.NewGuid().ToString()], - EnabledDocumentTypes = ["articlePage"] - } - ] - })); + var options = Options.Create(new WebAnalyticsOptions + { + Enabled = true, + Providers = { Vercel = { AccessToken = "secret" } }, + CacheDuration = cacheDuration ?? TimeSpan.FromMinutes(5), + Connections = + [ + new() + { + Key = MainKey, + DisplayName = "Main", + ProjectId = "project", + DocumentRootKeys = [Guid.NewGuid().ToString()], + EnabledDocumentTypes = ["articlePage"] + } + ] + }); + return new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(options), options); + } - private sealed class CountingClient : IVercelAnalyticsClient + private sealed class CountingClient : + IAnalyticsProviderClient, + IAnalyticsEventsProviderClient, + IAnalyticsEventPropertyDiscoveryProviderClient, + IAnalyticsFlagsProviderClient { + public AnalyticsProviderDefinition Definition => AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel); + public int CountCalls { get; private set; } public int TrendCalls { get; private set; } - public int PageViewTotalCalls { get; private set; } public int BreakdownCalls { get; private set; } public int EventCalls { get; private set; } public int EventCountCalls { get; private set; } @@ -457,17 +453,16 @@ private sealed class CountingClient : IVercelAnalyticsClient public CancellationToken LastCountCancellationToken { get; private set; } public Exception? CurrentCountException { get; init; } public Exception? PreviousCountException { get; init; } - public Exception? PreviousPageViewTotalException { get; init; } public Action? BeforePreviousCountFailure { get; init; } public TaskCompletionSource? PreviousCountStarted { get; init; } public TaskCompletionSource? PreviousCountRelease { get; init; } public List CountQueries { get; } = []; public List<(string EventName, AnalyticsEventDataFilter? Filter)> EventDetailCalls { get; } = []; - public Task GetProjectNameAsync(VercelAnalyticsConnection connection, CancellationToken cancellationToken) => + public Task GetDisplayNameAsync(AnalyticsConnection connection, CancellationToken cancellationToken) => Task.FromResult(connection.DisplayName); - public async Task CountAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) + public async Task GetTotalsAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); LastCountCancellationToken = cancellationToken; @@ -490,7 +485,7 @@ public async Task CountAsync(VercelAnalyticsConnection connecti } if (FailPreviousCount && query.To <= new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero)) { - throw new VercelAnalyticsApiException(System.Net.HttpStatusCode.PaymentRequired); + throw new AnalyticsProviderApiException(System.Net.HttpStatusCode.PaymentRequired, Definition.Provider); } CountStarted?.TrySetResult(); @@ -502,18 +497,7 @@ public async Task CountAsync(VercelAnalyticsConnection connecti return new AnalyticsTotals(20, 10); } - public Task GetPageViewTotalAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - PageViewTotalCalls++; - if (query.To <= new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero) && PreviousPageViewTotalException is not null) - { - return Task.FromException(PreviousPageViewTotalException); - } - return Task.FromResult(20L); - } - - public Task> GetTrendAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) + public Task> GetTrendAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); TrendCalls++; @@ -522,14 +506,20 @@ public Task> GetTrendAsync(VercelAnalyticsConnecti ]); } - public Task> GetBreakdownAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension dimension, int limit, string? search, CancellationToken cancellationToken) + public Task> GetBreakdownAsync(AnalyticsConnection connection, AnalyticsQuery query, AnalyticsDimension dimension, int limit, string? search, CancellationToken cancellationToken, AnalyticsTrafficMetric? orderBy = null) { cancellationToken.ThrowIfCancellationRequested(); BreakdownCalls++; return Task.FromResult>([]); } - public Task CountEventsAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) + public Task CountEventsAsync(AnalyticsConnection connection, AnalyticsQuery query, string eventName, CancellationToken cancellationToken) => + CountEvents(connection, eventName, null, cancellationToken); + + public Task CountFilteredEventsAsync(AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter eventDataFilter, CancellationToken cancellationToken) => + CountEvents(connection, eventName, eventDataFilter, cancellationToken); + + private Task CountEvents(AnalyticsConnection connection, string eventName, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); EventCountCalls++; @@ -538,27 +528,38 @@ public Task CountEventsAsync(VercelAnalyticsConnection con return Task.FromResult(new AnalyticsEventTotals(30, 12)); } - public Task> GetEventsAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, int limit, string? search, CancellationToken cancellationToken) + public Task> GetEventsAsync(AnalyticsConnection connection, AnalyticsQuery query, int limit, string? search, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); EventCalls++; return Task.FromResult>([]); } - public Task> GetFlagsAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, string? flagKey, int limit, CancellationToken cancellationToken) + public Task> GetFlagsAsync(AnalyticsConnection connection, AnalyticsQuery query, string? flagKey, int limit, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult>([]); } - public Task> GetEventPropertyNamesAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) + public Task> GetEventPropertyNamesAsync(AnalyticsConnection connection, AnalyticsQuery query, string eventName, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); EventPropertyNameCalls++; return Task.FromResult>(["plan"]); } - public Task> GetEventPropertyValuesAsync(VercelAnalyticsConnection connection, AnalyticsQuery query, string eventName, string propertyName, int limit, string? search, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) + public Task>> DiscoverEventPropertiesAsync(AnalyticsConnection connection, AnalyticsQuery query, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EventPropertyNameCalls++; + return Task.FromResult>>( + new Dictionary>(StringComparer.Ordinal) + { + ["Signup"] = [new AnalyticsEventProperty("plan", [new("Pro", 20, 10)])] + }); + } + + public Task> GetEventPropertyValuesAsync(AnalyticsConnection connection, AnalyticsQuery query, string eventName, string propertyName, int limit, string? search, AnalyticsEventDataFilter? eventDataFilter, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); EventPropertyValueCalls++; diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs new file mode 100644 index 0000000..4db2226 --- /dev/null +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs @@ -0,0 +1,398 @@ +using Microsoft.Extensions.Options; +using Moq; +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Tests.Services; + +public sealed class MockAnalyticsClientTests +{ + private static readonly Guid MockKey = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + [Fact] + public void Registry_only_activates_mock_connections_when_enabled() + { + var developmentRegistry = CreateRegistry(MockAnalyticsScenario.Complete, mockConnectionsEnabled: true); + var productionRegistry = CreateRegistry(MockAnalyticsScenario.Complete, mockConnectionsEnabled: false); + + var activeConnection = developmentRegistry.Get(MockKey); + + Assert.NotNull(activeConnection); + Assert.True(activeConnection.IsMock); + Assert.True(activeConnection.IsConfigured); + Assert.Null(productionRegistry.Get(MockKey)); + Assert.Equal(MockAnalyticsScenario.Complete, Assert.Single(productionRegistry.Settings.Connections).MockScenario); + } + + [Fact] + public void Registry_disables_mock_connections_unless_the_package_harness_opts_in() + { + var options = Options.Create(new WebAnalyticsOptions + { + Connections = + [ + new() { Key = MockKey, DisplayName = "Mock analytics", MockScenario = MockAnalyticsScenario.Complete } + ] + }); + var disabled = new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(options), options); + options.Value.EnableMockConnections = true; + var enabled = new AnalyticsConnectionRegistry(new WebAnalyticsSettingsStore(options), options); + + Assert.False(disabled.MockConnectionsEnabled); + Assert.Null(disabled.Get(MockKey)); + Assert.True(enabled.MockConnectionsEnabled); + Assert.NotNull(enabled.Get(MockKey)); + } + + [Fact] + public async Task Scenarios_expose_their_targeted_reports() + { + var client = new MockAnalyticsClient(); + var query = CreateQuery(); + var utm = CreateRegistry(MockAnalyticsScenario.Utm, true).Get(MockKey)!; + var flags = CreateRegistry(MockAnalyticsScenario.Flags, true).Get(MockKey)!; + var events = CreateRegistry(MockAnalyticsScenario.Events, true).Get(MockKey)!; + + var utmRows = await client.GetBreakdownAsync(utm, query, AnalyticsDimension.UtmCampaign, 10, null, CancellationToken.None); + var flagRows = await client.GetFlagsAsync(flags, query, null, 10, CancellationToken.None); + var eventRows = await client.GetEventsAsync(events, query, 10, null, CancellationToken.None); + + Assert.Contains(utmRows, row => row.Value == "summer-launch"); + Assert.Contains(flagRows, row => row.Value == "new-pricing-page"); + Assert.Contains(eventRows, row => row.EventName == "Signup completed"); + } + + [Fact] + public async Task Demo_exposes_every_report_with_a_non_repeating_trend() + { + var client = new MockAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var query = new AnalyticsQuery( + MockKey, + new DateOnly(2026, 6, 1), + new DateOnly(2026, 7, 15), + AnalyticsInterval.Day); + + var trend = await client.GetTrendAsync(connection, query, CancellationToken.None); + var utmRows = await client.GetBreakdownAsync(connection, query, AnalyticsDimension.UtmCampaign, 10, null, CancellationToken.None); + var flagRows = await client.GetFlagsAsync(connection, query, null, 10, CancellationToken.None); + var eventRows = await client.GetEventsAsync(connection, query, 10, null, CancellationToken.None); + + Assert.NotEmpty(utmRows); + Assert.NotEmpty(flagRows); + Assert.NotEmpty(eventRows); + Assert.True(trend.Select(point => point.PageViews).Distinct().Count() >= trend.Count * 0.9); + } + + [Fact] + public async Task Demo_totals_increase_slightly_over_the_previous_period() + { + var client = new MockAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var current = new AnalyticsQuery( + MockKey, + new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), + AnalyticsInterval.Day); + var previous = current with + { + From = current.From - (current.To - current.From), + To = current.From + }; + + var currentTotals = await client.CountAsync(connection, current, CancellationToken.None); + var previousTotals = await client.CountAsync(connection, previous, CancellationToken.None); + var visitorGrowth = (double)currentTotals.Visitors / previousTotals.Visitors; + var pageViewGrowth = (double)currentTotals.PageViews / previousTotals.PageViews; + + Assert.InRange(visitorGrowth, 1.01d, 1.04d); + Assert.InRange(pageViewGrowth, 1.01d, 1.04d); + } + + [Fact] + public async Task Demo_filters_update_matching_and_cross_dimension_reports() + { + var client = new MockAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var baseline = CreateQuery(); + var filtered = baseline with + { + Filters = [new AnalyticsFilter(AnalyticsDimension.Country, "DK")] + }; + + var baselineDevices = await client.GetBreakdownAsync( + connection, baseline, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); + var filteredDevices = await client.GetBreakdownAsync( + connection, filtered, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); + var filteredCountries = await client.GetBreakdownAsync( + connection, filtered, AnalyticsDimension.Country, 10, null, CancellationToken.None); + + Assert.All(filteredDevices.Zip(baselineDevices), pair => + Assert.True(pair.First.Visitors < pair.Second.Visitors)); + Assert.Equal("DK", Assert.Single(filteredCountries).Value); + } + + [Fact] + public async Task Demo_event_filter_returns_only_the_selected_event() + { + var client = new MockAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var query = CreateQuery() with + { + Filters = [new AnalyticsFilter(AnalyticsDimension.EventName, "Demo requested")] + }; + + var events = await client.GetEventsAsync(connection, query, 10, null, CancellationToken.None); + + Assert.Equal("Demo requested", Assert.Single(events).EventName); + } + + [Fact] + public async Task Resolver_serves_mock_reports_without_contacting_providers() + { + var handler = new RejectingHttpMessageHandler(); + var router = new AnalyticsProviderClientResolver( + [ + new VercelAnalyticsClient(new HttpClient(handler), new AnalyticsProviderRequestGate()), + new PlausibleAnalyticsClient(new HttpClient(handler), new AnalyticsProviderRequestGate()) + ], + new MockAnalyticsClient()); + using var cache = new AnalyticsReportCache(); + var service = new AnalyticsReportService( + CreateRegistry(MockAnalyticsScenario.Complete, true), + router, + cache); + + var summary = await service.GetSummaryAsync(CreateQuery(), CancellationToken.None); + + Assert.NotNull(summary); + Assert.NotEmpty(summary.Points); + Assert.True(summary.Totals.PageViews > 0); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void Resolver_rejects_duplicate_provider_clients() + { + var handler = new RejectingHttpMessageHandler(); + var gate = new AnalyticsProviderRequestGate(); + + var exception = Assert.Throws(() => new AnalyticsProviderClientResolver( + [ + new VercelAnalyticsClient(new HttpClient(handler), gate), + new VercelAnalyticsClient(new HttpClient(handler), gate), + new PlausibleAnalyticsClient(new HttpClient(handler), gate) + ], + new MockAnalyticsClient())); + + Assert.Contains("Multiple analytics clients", exception.Message); + } + + [Fact] + public void Resolver_rejects_missing_provider_clients() + { + var exception = Assert.Throws(() => new AnalyticsProviderClientResolver( + [new VercelAnalyticsClient(new HttpClient(new RejectingHttpMessageHandler()), new AnalyticsProviderRequestGate())], + new MockAnalyticsClient())); + + Assert.Contains("No analytics client is registered for Plausible", exception.Message); + } + + [Fact] + public void Capability_factory_rejects_global_event_filtering_without_event_operations() + { + var exception = Assert.Throws(() => + AnalyticsProviderCapabilities.FromClient( + [], + globalEventFiltering: true, + breakdownOrdering: false)); + + Assert.Contains(nameof(IAnalyticsEventsProviderClient), exception.Message); + } + + [Fact] + public void Resolver_dispatches_an_asymmetric_client_using_the_closest_existing_provider_identity() + { + // AnalyticsProvider is currently a closed enum, so this extension-seam double uses Vercel. + var asymmetric = new AsymmetricAnalyticsClient(AnalyticsProvider.Vercel); + var resolver = CreateResolver(asymmetric); + + var client = resolver.Get(new AnalyticsConnection( + MockKey, + "Asymmetric", + AnalyticsProvider.Vercel, + "", + "project", + null, + "", + [], + [], + false, + new HashSet(), + new HashSet())); + + Assert.Same(asymmetric, client); + Assert.True(client.Definition.Capabilities.Events); + Assert.True(client.Definition.Capabilities.EventDetails); + Assert.False(client.Definition.Capabilities.EventProperties); + Assert.False(client.Definition.Capabilities.Flags); + } + + [Fact] + public void Resolver_reports_unsupported_typed_operations() + { + var asymmetric = new AsymmetricAnalyticsClient(AnalyticsProvider.Vercel); + var resolver = CreateResolver(asymmetric); + var connection = new AnalyticsConnection( + MockKey, + "Asymmetric", + AnalyticsProvider.Vercel, + "", + "project", + null, + "", + [], + [], + false, + new HashSet(), + new HashSet()); + + var exception = Assert.Throws(() => + resolver.Get(connection)); + + Assert.Contains(nameof(IAnalyticsFlagsProviderClient), exception.Message); + } + + private static AnalyticsProviderClientResolver CreateResolver(IAnalyticsProviderClient vercelClient) => new( + [vercelClient, new PlausibleAnalyticsClient(new HttpClient(new RejectingHttpMessageHandler()), new AnalyticsProviderRequestGate())], + new MockAnalyticsClient()); + + private static AnalyticsProviderDefinition CreateDefinition(AnalyticsProvider provider) => new( + provider, + AnalyticsProviderCapabilities.FromClient( + [], + globalEventFiltering: false, + breakdownOrdering: false), + new( + AnalyticsConnectionIdentifier.ProjectId, + "Test project identifier.", + "a project ID"), + new( + "Test provider", + "test", + new("team", "team", "Optional test team."), + new("credential", "Test credential.", "https://example.test"), + null), + _ => string.Empty); + + private static AnalyticsQuery CreateQuery() => new( + MockKey, + new DateOnly(2026, 7, 1), + new DateOnly(2026, 7, 15), + AnalyticsInterval.Day); + + private static AnalyticsConnectionRegistry CreateRegistry( + MockAnalyticsScenario scenario, + bool mockConnectionsEnabled) + { + var options = Options.Create(new WebAnalyticsOptions + { + Enabled = true, + Connections = + [ + new() + { + Key = MockKey, + DisplayName = "Mock analytics", + MockScenario = scenario + } + ] + }); + return new AnalyticsConnectionRegistry( + new WebAnalyticsSettingsStore(options), + options, + mockConnectionsEnabled); + } + + private sealed class RejectingHttpMessageHandler : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + throw new InvalidOperationException("Mock connections must not contact Vercel."); + } + } + + private sealed class CoreOnlyAnalyticsClient : IAnalyticsProviderClient + { + public AnalyticsProviderDefinition Definition => + AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel); + + public Task GetDisplayNameAsync(AnalyticsConnection connection, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetTotalsAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> GetTrendAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> GetBreakdownAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsDimension dimension, + int limit, + string? search, + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) => throw new NotSupportedException(); + } + + private sealed class AsymmetricAnalyticsClient : + IAnalyticsProviderClient, + IAnalyticsEventDetailsProviderClient + { + public AsymmetricAnalyticsClient(AnalyticsProvider provider) + { + Definition = CreateDefinition(provider); + } + + public AnalyticsProviderDefinition Definition { get; } + + public Task GetDisplayNameAsync(AnalyticsConnection connection, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GetTotalsAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> GetTrendAsync(AnalyticsConnection connection, AnalyticsQuery query, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task> GetBreakdownAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsDimension dimension, + int limit, + string? search, + CancellationToken cancellationToken, + AnalyticsTrafficMetric? orderBy = null) => throw new NotSupportedException(); + + public Task> GetEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + int limit, + string? search, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task CountEventsAsync( + AnalyticsConnection connection, + AnalyticsQuery query, + string eventName, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } + +} diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs deleted file mode 100644 index d7d626c..0000000 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using Microsoft.Extensions.Options; -using TheBuilder.WebAnalytics.Configuration; -using TheBuilder.WebAnalytics.Models; -using TheBuilder.WebAnalytics.Services; - -namespace TheBuilder.WebAnalytics.Tests.Services; - -public sealed class MockVercelAnalyticsClientTests -{ - private static readonly Guid MockKey = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - - [Fact] - public void Registry_only_activates_mock_connections_when_enabled() - { - var developmentRegistry = CreateRegistry(MockAnalyticsScenario.Complete, mockConnectionsEnabled: true); - var productionRegistry = CreateRegistry(MockAnalyticsScenario.Complete, mockConnectionsEnabled: false); - - var activeConnection = developmentRegistry.Get(MockKey); - - Assert.NotNull(activeConnection); - Assert.True(activeConnection.IsMock); - Assert.True(activeConnection.IsConfigured); - Assert.Null(productionRegistry.Get(MockKey)); - Assert.Equal(MockAnalyticsScenario.Complete, Assert.Single(productionRegistry.Settings.Connections).MockScenario); - } - - [Fact] - public void Registry_disables_mock_connections_unless_the_package_harness_opts_in() - { - var options = Options.Create(new VercelAnalyticsOptions - { - Connections = - [ - new() { Key = MockKey, DisplayName = "Mock analytics", MockScenario = MockAnalyticsScenario.Complete } - ] - }); - var disabled = new VercelAnalyticsConnectionRegistry(new VercelAnalyticsSettingsStore(options), options); - options.Value.EnableMockConnections = true; - var enabled = new VercelAnalyticsConnectionRegistry(new VercelAnalyticsSettingsStore(options), options); - - Assert.False(disabled.MockConnectionsEnabled); - Assert.Null(disabled.Get(MockKey)); - Assert.True(enabled.MockConnectionsEnabled); - Assert.NotNull(enabled.Get(MockKey)); - } - - [Fact] - public async Task Scenarios_expose_their_targeted_reports() - { - var client = new MockVercelAnalyticsClient(); - var query = CreateQuery(); - var utm = CreateRegistry(MockAnalyticsScenario.Utm, true).Get(MockKey)!; - var flags = CreateRegistry(MockAnalyticsScenario.Flags, true).Get(MockKey)!; - var events = CreateRegistry(MockAnalyticsScenario.Events, true).Get(MockKey)!; - - var utmRows = await client.GetBreakdownAsync(utm, query, AnalyticsDimension.UtmCampaign, 10, null, CancellationToken.None); - var flagRows = await client.GetFlagsAsync(flags, query, null, 10, CancellationToken.None); - var eventRows = await client.GetEventsAsync(events, query, 10, null, CancellationToken.None); - - Assert.Contains(utmRows, row => row.Value == "summer-launch"); - Assert.Contains(flagRows, row => row.Value == "new-pricing-page"); - Assert.Contains(eventRows, row => row.EventName == "Signup completed"); - } - - [Fact] - public async Task Demo_exposes_every_report_with_a_non_repeating_trend() - { - var client = new MockVercelAnalyticsClient(); - var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; - var query = new AnalyticsQuery( - MockKey, - new DateOnly(2026, 6, 1), - new DateOnly(2026, 7, 15), - AnalyticsInterval.Day); - - var trend = await client.GetTrendAsync(connection, query, CancellationToken.None); - var utmRows = await client.GetBreakdownAsync(connection, query, AnalyticsDimension.UtmCampaign, 10, null, CancellationToken.None); - var flagRows = await client.GetFlagsAsync(connection, query, null, 10, CancellationToken.None); - var eventRows = await client.GetEventsAsync(connection, query, 10, null, CancellationToken.None); - - Assert.NotEmpty(utmRows); - Assert.NotEmpty(flagRows); - Assert.NotEmpty(eventRows); - Assert.True(trend.Select(point => point.PageViews).Distinct().Count() >= trend.Count * 0.9); - } - - [Fact] - public async Task Demo_totals_increase_slightly_over_the_previous_period() - { - var client = new MockVercelAnalyticsClient(); - var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; - var current = new AnalyticsQuery( - MockKey, - new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero), - new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), - AnalyticsInterval.Day); - var previous = current with - { - From = current.From - (current.To - current.From), - To = current.From - }; - - var currentTotals = await client.CountAsync(connection, current, CancellationToken.None); - var previousTotals = await client.CountAsync(connection, previous, CancellationToken.None); - var visitorGrowth = (double)currentTotals.Visitors / previousTotals.Visitors; - var pageViewGrowth = (double)currentTotals.PageViews / previousTotals.PageViews; - - Assert.InRange(visitorGrowth, 1.01d, 1.04d); - Assert.InRange(pageViewGrowth, 1.01d, 1.04d); - } - - [Fact] - public async Task Demo_filters_update_matching_and_cross_dimension_reports() - { - var client = new MockVercelAnalyticsClient(); - var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; - var baseline = CreateQuery(); - var filtered = baseline with - { - Filters = [new AnalyticsFilter(AnalyticsDimension.Country, "DK")] - }; - - var baselineDevices = await client.GetBreakdownAsync( - connection, baseline, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); - var filteredDevices = await client.GetBreakdownAsync( - connection, filtered, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); - var filteredCountries = await client.GetBreakdownAsync( - connection, filtered, AnalyticsDimension.Country, 10, null, CancellationToken.None); - - Assert.All(filteredDevices.Zip(baselineDevices), pair => - Assert.True(pair.First.Visitors < pair.Second.Visitors)); - Assert.Equal("DK", Assert.Single(filteredCountries).Value); - } - - [Fact] - public async Task Demo_event_filter_returns_only_the_selected_event() - { - var client = new MockVercelAnalyticsClient(); - var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; - var query = CreateQuery() with - { - Filters = [new AnalyticsFilter(AnalyticsDimension.EventName, "Demo requested")] - }; - - var events = await client.GetEventsAsync(connection, query, 10, null, CancellationToken.None); - - Assert.Equal("Demo requested", Assert.Single(events).EventName); - } - - [Fact] - public async Task Router_serves_mock_reports_without_contacting_Vercel() - { - var handler = new RejectingHttpMessageHandler(); - var router = new VercelAnalyticsClientRouter( - new VercelAnalyticsClient(new HttpClient(handler), new VercelAnalyticsRequestGate()), - new MockVercelAnalyticsClient()); - using var cache = new AnalyticsReportCache(); - var service = new VercelAnalyticsReportService( - CreateRegistry(MockAnalyticsScenario.Complete, true), - router, - cache); - - var summary = await service.GetSummaryAsync(CreateQuery(), CancellationToken.None); - - Assert.NotNull(summary); - Assert.NotEmpty(summary.Points); - Assert.True(summary.Totals.PageViews > 0); - Assert.Equal(0, handler.RequestCount); - } - - private static AnalyticsQuery CreateQuery() => new( - MockKey, - new DateOnly(2026, 7, 1), - new DateOnly(2026, 7, 15), - AnalyticsInterval.Day); - - private static VercelAnalyticsConnectionRegistry CreateRegistry( - MockAnalyticsScenario scenario, - bool mockConnectionsEnabled) - { - var options = Options.Create(new VercelAnalyticsOptions - { - Enabled = true, - Connections = - [ - new() - { - Key = MockKey, - DisplayName = "Mock analytics", - MockScenario = scenario - } - ] - }); - return new VercelAnalyticsConnectionRegistry( - new VercelAnalyticsSettingsStore(options), - options, - mockConnectionsEnabled); - } - - private sealed class RejectingHttpMessageHandler : HttpMessageHandler - { - public int RequestCount { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - RequestCount++; - throw new InvalidOperationException("Mock connections must not contact Vercel."); - } - } -} diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs new file mode 100644 index 0000000..c6449a4 --- /dev/null +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs @@ -0,0 +1,416 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using TheBuilder.WebAnalytics.Configuration; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Tests.Services; + +public sealed class PlausibleAnalyticsClientTests +{ + [Fact] + public async Task Count_posts_authenticated_v2_query_with_exclusive_end_adjusted() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[42,31]}]}"""); + var client = CreateClient(handler); + + var result = await client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None); + + Assert.Equal(new AnalyticsTotals(42, 31), result); + Assert.Equal(HttpMethod.Post, handler.Request?.Method); + Assert.Equal("Bearer secret", handler.Request?.Headers.Authorization?.ToString()); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal("example.com", body.RootElement.GetProperty("site_id").GetString()); + Assert.Equal("2026-07-02T23:59:59.9999999+00:00", body.RootElement.GetProperty("date_range")[1].GetString()); + } + + [Fact] + public async Task Count_applies_a_global_event_name_filter() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[15,12]}]}"""); + var client = CreateClient(handler); + var query = CreateQuery() with + { + Filters = [new AnalyticsFilter(AnalyticsDimension.EventName, "Read case")] + }; + + var result = await client.CountAsync(CreateConnection(), query, CancellationToken.None); + + Assert.Equal(new AnalyticsTotals(15, 12), result); + using var body = JsonDocument.Parse(handler.Body!); + var filter = Assert.Single(body.RootElement.GetProperty("filters").EnumerateArray()); + Assert.Equal("is", filter[0].GetString()); + Assert.Equal("event:goal", filter[1].GetString()); + Assert.Equal("Read case", filter[2][0].GetString()); + } + + [Fact] + public async Task Breakdown_maps_common_dimension_filters_and_search() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["Google"],"metrics":[12,8]}]}"""); + var client = CreateClient(handler); + var query = CreateQuery() with + { + RequestPath = "/news", + Filters = [new AnalyticsFilter(AnalyticsDimension.Country, "Denmark")] + }; + + var rows = await client.GetBreakdownAsync( + CreateConnection(), query, AnalyticsDimension.Referrer, 10, "goo", CancellationToken.None); + + Assert.Equal(new AnalyticsBreakdownRow("Google", 12, 8), Assert.Single(rows)); + Assert.Contains("visit:referrer", handler.Body); + Assert.Contains("visit:country", handler.Body); + Assert.Contains("event:page", handler.Body); + Assert.Contains("contains", handler.Body); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal("visitors", body.RootElement.GetProperty("order_by")[0][0].GetString()); + } + + [Fact] + public async Task Breakdown_can_order_by_page_views() + { + var handler = new RecordingHandler("""{"results":[]}"""); + var client = CreateClient(handler); + + await client.GetBreakdownAsync( + CreateConnection(), + CreateQuery(), + AnalyticsDimension.RequestPath, + 10, + null, + CancellationToken.None, + AnalyticsTrafficMetric.PageViews); + + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal("pageviews", body.RootElement.GetProperty("order_by")[0][0].GetString()); + Assert.Equal("desc", body.RootElement.GetProperty("order_by")[0][1].GetString()); + } + + [Fact] + public async Task Events_map_plausible_goals() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["Signup"],"metrics":[9,7]}]}"""); + var client = CreateClient(handler); + + var rows = await client.GetEventsAsync(CreateConnection(), CreateQuery(), 20, null, CancellationToken.None); + + Assert.Equal(new AnalyticsEventRow("Signup", 9, 7), Assert.Single(rows)); + Assert.Contains("event:goal", handler.Body); + Assert.Contains("events", handler.Body); + } + + [Fact] + public async Task Event_details_count_the_selected_plausible_goal() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[9,7]}]}"""); + var client = CreateClient(handler); + + var totals = await client.CountEventsAsync( + CreateConnection(), CreateQuery(), "Read article", CancellationToken.None); + + Assert.Equal(new AnalyticsEventTotals(9, 7), totals); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal("events", body.RootElement.GetProperty("metrics")[0].GetString()); + Assert.Equal("is", body.RootElement.GetProperty("filters")[0][0].GetString()); + Assert.Equal("event:goal", body.RootElement.GetProperty("filters")[0][1].GetString()); + Assert.Equal("Read article", body.RootElement.GetProperty("filters")[0][2][0].GetString()); + } + + [Fact] + public async Task Event_properties_include_configured_names_and_event_specific_defaults() + { + var client = CreateClient(new RecordingHandler("{}")); + var connection = CreateConnection(["locale", "title", "URL"]); + + var custom = await client.GetEventPropertyNamesAsync( + connection, CreateQuery(), "Read case", null, CancellationToken.None); + var outbound = await client.GetEventPropertyNamesAsync( + connection, CreateQuery(), "Outbound Link: Click", null, CancellationToken.None); + var notFound = await client.GetEventPropertyNamesAsync( + connection, CreateQuery(), "404", null, CancellationToken.None); + + Assert.Equal(["locale", "title", "URL"], custom); + Assert.Equal(["url", "locale", "title"], outbound); + Assert.Equal(["path", "locale", "title", "URL"], notFound); + } + + [Fact] + public async Task Event_property_discovery_groups_only_non_empty_properties_by_event() + { + var handler = new DiscoveryHandler(); + var client = CreateClient(handler); + + var result = await client.DiscoverEventPropertiesAsync( + CreateConnection(["locale", "title"]), CreateQuery(), null, CancellationToken.None); + + Assert.Equal(["locale", "title"], result["Read case"].Select(property => property.Name)); + Assert.Equal(["url"], result["Outbound Link: Click"].Select(property => property.Name)); + Assert.Equal(["path"], result["404"].Select(property => property.Name)); + Assert.Equal(new AnalyticsEventPropertyValue("da-DK", 15, 12), Assert.Single(result["Read case"][0].Values)); + Assert.DoesNotContain("Download", result.Keys); + Assert.All(handler.Bodies, body => + { + using var request = JsonDocument.Parse(body); + Assert.Equal("event:goal", request.RootElement.GetProperty("dimensions")[0].GetString()); + Assert.StartsWith("event:props:", request.RootElement.GetProperty("dimensions")[1].GetString()); + }); + } + + [Fact] + public async Task Event_property_values_query_the_named_dimension_lazily() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["da-DK"],"metrics":[15,12]}]}"""); + var client = CreateClient(handler); + + var values = await client.GetEventPropertyValuesAsync( + CreateConnection(["locale"]), + CreateQuery(), + "Read case", + "locale", + 20, + "da", + null, + CancellationToken.None); + + Assert.Equal(new AnalyticsEventPropertyValue("da-DK", 15, 12), Assert.Single(values)); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal("event:props:locale", body.RootElement.GetProperty("dimensions")[0].GetString()); + Assert.Equal("events", body.RootElement.GetProperty("order_by")[0][0].GetString()); + Assert.Contains(body.RootElement.GetProperty("filters").EnumerateArray(), filter => + filter[0].GetString() == "is" && filter[1].GetString() == "event:goal"); + Assert.Contains(body.RootElement.GetProperty("filters").EnumerateArray(), filter => + filter[0].GetString() == "contains" && filter[1].GetString() == "event:props:locale"); + } + + [Fact] + public async Task Event_property_filter_is_applied_to_event_totals() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[8,6]}]}"""); + var client = CreateClient(handler); + + var totals = await client.CountFilteredEventsAsync( + CreateConnection(["locale"]), + CreateQuery(), + "Read case", + new AnalyticsEventDataFilter("locale", "da-DK"), + CancellationToken.None); + + Assert.Equal(new AnalyticsEventTotals(8, 6), totals); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Contains(body.RootElement.GetProperty("filters").EnumerateArray(), filter => + filter[0].GetString() == "is" && + filter[1].GetString() == "event:props:locale" && + filter[2][0].GetString() == "da-DK"); + } + + [Fact] + public async Task Unconfigured_event_property_is_not_queried() + { + var handler = new RecordingHandler("""{"results":[]}"""); + var client = CreateClient(handler); + + var values = await client.GetEventPropertyValuesAsync( + CreateConnection(["locale"]), + CreateQuery(), + "Read case", + "private-property", + 20, + null, + null, + CancellationToken.None); + + Assert.Empty(values); + Assert.Null(handler.Request); + } + + [Fact] + public async Task Trend_preserves_reporting_timezone_labels_and_fills_empty_buckets() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["2026-07-02"],"metrics":[12,8]}],"meta":{"time_labels":["2026-07-01","2026-07-02","2026-07-03"]}}"""); + var client = CreateClient(handler); + + var points = await client.GetTrendAsync(CreateConnection(), CreateQuery(), CancellationToken.None); + + Assert.Equal(["2026-07-01", "2026-07-02", "2026-07-03"], points.Select(point => point.Timestamp)); + Assert.Equal([0L, 12L, 0L], points.Select(point => point.PageViews)); + using var body = JsonDocument.Parse(handler.Body!); + Assert.True(body.RootElement.GetProperty("include").GetProperty("time_labels").GetBoolean()); + Assert.Equal("time:day", body.RootElement.GetProperty("dimensions")[0].GetString()); + } + + [Fact] + public async Task Hourly_trend_keeps_provider_local_wall_clock_time() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["2026-07-01 23:00:00"],"metrics":[4,3]}],"meta":{"time_labels":["2026-07-01 23:00:00"]}}"""); + var client = CreateClient(handler); + var query = CreateQuery() with { Interval = AnalyticsInterval.Hour }; + + var point = Assert.Single(await client.GetTrendAsync(CreateConnection(), query, CancellationToken.None)); + + Assert.Equal("2026-07-01T23:00:00", point.Timestamp); + } + + [Fact] + public async Task Referrer_breakdown_preserves_provider_values_and_metrics() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":["https://www.example.com/one"],"metrics":[5,3]},{"dimensions":["example.com/two"],"metrics":[7,4]}]}"""); + var client = CreateClient(handler); + + var rows = await client.GetBreakdownAsync( + CreateConnection(), CreateQuery(), AnalyticsDimension.Referrer, 10, null, CancellationToken.None); + + Assert.Equal([ + new AnalyticsBreakdownRow("https://www.example.com/one", 5, 3), + new AnalyticsBreakdownRow("example.com/two", 7, 4) + ], rows); + using var body = JsonDocument.Parse(handler.Body!); + Assert.Equal(10, body.RootElement.GetProperty("pagination").GetProperty("limit").GetInt32()); + } + + [Fact] + public async Task Empty_aggregate_returns_zero_totals() + { + var client = CreateClient(new RecordingHandler("""{"results":[]}""")); + + var totals = await client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None); + + Assert.Equal(new AnalyticsTotals(0, 0), totals); + } + + [Fact] + public async Task Multiple_aggregate_rows_are_rejected() + { + var client = CreateClient(new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[1,1]},{"dimensions":[],"metrics":[2,2]}]}""")); + + await Assert.ThrowsAsync(() => + client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None)); + } + + [Fact] + public async Task Rows_with_an_unexpected_shape_are_rejected() + { + var client = CreateClient(new RecordingHandler("""{"results":[{"dimensions":["DK"],"metrics":[1]}]}""")); + + await Assert.ThrowsAsync(() => + client.GetBreakdownAsync(CreateConnection(), CreateQuery(), AnalyticsDimension.Country, 10, null, CancellationToken.None)); + } + + [Fact] + public async Task Rate_limit_preserves_provider_identity_without_exposing_token() + { + var client = CreateClient(new RecordingHandler("{}", HttpStatusCode.TooManyRequests)); + + var exception = await Assert.ThrowsAsync(() => + client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None)); + + Assert.Equal(AnalyticsProvider.Plausible, exception.Provider); + Assert.Equal(HttpStatusCode.TooManyRequests, exception.StatusCode); + Assert.DoesNotContain("secret", exception.ToString()); + } + + [Fact] + public async Task Missing_results_are_rejected_as_an_untrusted_payload() + { + var client = CreateClient(new RecordingHandler("{}")); + + await Assert.ThrowsAsync(() => + client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None)); + } + + [Theory] + [InlineData(AnalyticsDimension.RequestPath, "event:page")] + [InlineData(AnalyticsDimension.Referrer, "visit:referrer")] + [InlineData(AnalyticsDimension.UtmCampaign, "visit:utm_campaign")] + [InlineData(AnalyticsDimension.UtmTerm, "visit:utm_term")] + [InlineData(AnalyticsDimension.UtmContent, "visit:utm_content")] + [InlineData(AnalyticsDimension.Country, "visit:country")] + [InlineData(AnalyticsDimension.BrowserName, "visit:browser")] + public void Dimensions_are_mapped_to_plausible(AnalyticsDimension dimension, string expected) => + Assert.Equal(expected, PlausibleAnalyticsClient.ToApiDimension(dimension)); + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.PaymentRequired)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.TooManyRequests)] + public async Task Error_statuses_preserve_provider_identity(HttpStatusCode statusCode) + { + var client = CreateClient(new RecordingHandler("{}", statusCode)); + + var exception = await Assert.ThrowsAsync(() => + client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None)); + + Assert.Equal(AnalyticsProvider.Plausible, exception.Provider); + Assert.Equal(statusCode, exception.StatusCode); + } + + private static PlausibleAnalyticsClient CreateClient(HttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://plausible.io/") }, new AnalyticsProviderRequestGate()); + + private static AnalyticsConnection CreateConnection(IReadOnlyList? eventPropertyNames = null) => new( + Guid.Parse("11111111-1111-1111-1111-111111111110"), + "Plausible", + AnalyticsProvider.Plausible, + "secret", + string.Empty, + null, + "example.com", + eventPropertyNames ?? [], + [], + false, + new HashSet(), + new HashSet()); + + private static AnalyticsQuery CreateQuery() => new( + Guid.Parse("11111111-1111-1111-1111-111111111110"), + new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 3, 0, 0, 0, TimeSpan.Zero), + AnalyticsInterval.Day); + + private sealed class RecordingHandler(string body, HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + public string? Body { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + } + + private sealed class DiscoveryHandler : HttpMessageHandler + { + private readonly List bodies = []; + public IReadOnlyList Bodies => bodies; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + lock (bodies) bodies.Add(body); + var responseBody = body switch + { + var value when value.Contains("event:props:locale", StringComparison.Ordinal) => + """{"results":[{"dimensions":["Read case","da-DK"],"metrics":[15,12]},{"dimensions":["Download","(none)"],"metrics":[2,2]}]}""", + var value when value.Contains("event:props:title", StringComparison.Ordinal) => + """{"results":[{"dimensions":["Read case","Case title"],"metrics":[15,12]}]}""", + var value when value.Contains("event:props:url", StringComparison.Ordinal) => + """{"results":[{"dimensions":["Outbound Link: Click","https://example.com"],"metrics":[4,3]},{"dimensions":["Read case","https://example.com/case"],"metrics":[2,2]}]}""", + var value when value.Contains("event:props:path", StringComparison.Ordinal) => + """{"results":[{"dimensions":["404","/missing"],"metrics":[3,2]}]}""", + _ => throw new InvalidOperationException("Unexpected discovery query.") + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/TestAnalyticsProviderClientResolver.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/TestAnalyticsProviderClientResolver.cs new file mode 100644 index 0000000..5ac597d --- /dev/null +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/TestAnalyticsProviderClientResolver.cs @@ -0,0 +1,14 @@ +using TheBuilder.WebAnalytics.Configuration; + +namespace TheBuilder.WebAnalytics.Services; + +internal sealed class TestAnalyticsProviderClientResolver(IAnalyticsProviderClient client) + : IAnalyticsProviderClientResolver +{ + public IAnalyticsProviderClient Get(AnalyticsConnection _) => client; + + public TCapability Get(AnalyticsConnection _) + where TCapability : class => + client as TCapability + ?? throw new InvalidOperationException($"Test client does not support {typeof(TCapability).Name}."); +} diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs index 6a0db8a..8c7525b 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs @@ -18,7 +18,7 @@ public async Task Project_name_uses_project_endpoint_and_team_scope() var client = CreateClient(handler); var connection = CreateConnection(team: "team_123"); - var result = await client.GetProjectNameAsync(connection, CancellationToken.None); + var result = await client.GetDisplayNameAsync(connection, CancellationToken.None); Assert.Equal("health-platform", result); Assert.Equal("/v9/projects/project", handler.Request!.RequestUri!.AbsolutePath); @@ -195,6 +195,24 @@ public async Task Breakdown_parses_requested_dimension() Assert.Contains("by=country", handler.Request!.RequestUri!.Query); } + [Fact] + public async Task Breakdown_rejects_selectable_ordering_that_the_vercel_api_does_not_support() + { + var client = CreateClient(new RecordingHandler("""{"data":[]}""")); + var connection = CreateConnection(); + + var exception = await Assert.ThrowsAsync(() => client.GetBreakdownAsync( + connection, + new AnalyticsQuery(connection.Key, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 2), AnalyticsInterval.Day), + AnalyticsDimension.Country, + 10, + null, + CancellationToken.None, + AnalyticsTrafficMetric.PageViews)); + + Assert.Equal("orderBy", exception.ParamName); + } + [Theory] [InlineData(AnalyticsDimension.UtmTerm, "utmTerm")] [InlineData(AnalyticsDimension.UtmContent, "utmContent")] @@ -381,7 +399,6 @@ public async Task Event_count_combines_validated_route_and_escaped_event_filter( connection, new AnalyticsQuery(connection.Key, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 2), AnalyticsInterval.Day, "/editor's"), "CTA's click", - null, CancellationToken.None); Assert.Equal(new AnalyticsEventTotals(42, 31), result); @@ -401,7 +418,6 @@ public async Task Event_count_rejects_missing_count() connection, new AnalyticsQuery(connection.Key, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 2), AnalyticsInterval.Day), "Signup", - null, CancellationToken.None)); Assert.Contains("count", exception.Message, StringComparison.OrdinalIgnoreCase); @@ -439,7 +455,7 @@ await CreateClient(new RecordingHandler("""{"data":[{"requestPath":"/news","page Assert.Equal( new AnalyticsEventTotals(0, 0), await CreateClient(new RecordingHandler("""{"data":{"count":0,"visitors":0}}""")) - .CountEventsAsync(connection, query, "Signup", null, CancellationToken.None)); + .CountEventsAsync(connection, query, "Signup", CancellationToken.None)); var events = await CreateClient(new RecordingHandler( """{"data":[{"eventName":"Signup","count":0,"visitors":0}]}""")) @@ -559,7 +575,7 @@ public async Task Error_response_throws_sanitized_exception() var client = CreateClient(handler); var connection = CreateConnection(); - var exception = await Assert.ThrowsAsync(() => client.CountAsync( + var exception = await Assert.ThrowsAsync(() => client.CountAsync( connection, new AnalyticsQuery(connection.Key, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 2), AnalyticsInterval.Day), CancellationToken.None)); @@ -571,7 +587,7 @@ public async Task Error_response_throws_sanitized_exception() [Fact] public async Task Request_gate_bounds_real_http_operations_across_client_instances() { - using var gate = new VercelAnalyticsRequestGate(maximumConcurrentRequests: 2); + using var gate = new AnalyticsProviderRequestGate(maximumConcurrentRequests: 2); var handler = new BlockingHandler(expectedStarts: 2); var firstClient = CreateClient(handler, gate); var secondClient = CreateClient(handler, gate); @@ -606,7 +622,7 @@ public async Task Request_gate_bounds_real_http_operations_across_client_instanc [Fact] public async Task Request_gate_cancels_a_queued_operation_without_starting_http_work() { - using var gate = new VercelAnalyticsRequestGate(maximumConcurrentRequests: 1); + using var gate = new AnalyticsProviderRequestGate(maximumConcurrentRequests: 1); var handler = new BlockingHandler(expectedStarts: 1); var client = CreateClient(handler, gate); var connection = CreateConnection(); @@ -639,25 +655,26 @@ public async Task Request_gate_cancels_a_queued_operation_without_starting_http_ [Fact] public async Task Request_gate_releases_capacity_after_an_upstream_error() { - using var gate = new VercelAnalyticsRequestGate(maximumConcurrentRequests: 1); + using var gate = new AnalyticsProviderRequestGate(maximumConcurrentRequests: 1); var client = CreateClient(new SequencedHandler(HttpStatusCode.Forbidden, HttpStatusCode.OK), gate); var connection = CreateConnection(); var query = new AnalyticsQuery(connection.Key, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 2), AnalyticsInterval.Day); - await Assert.ThrowsAsync(() => client.CountAsync(connection, query, CancellationToken.None)); + await Assert.ThrowsAsync(() => client.CountAsync(connection, query, CancellationToken.None)); var result = await client.CountAsync(connection, query, CancellationToken.None); Assert.Equal(new AnalyticsTotals(42, 31), result); } private static VercelAnalyticsClient CreateClient(HttpMessageHandler handler) => - CreateClient(handler, new VercelAnalyticsRequestGate()); + CreateClient(handler, new AnalyticsProviderRequestGate()); - private static VercelAnalyticsClient CreateClient(HttpMessageHandler handler, VercelAnalyticsRequestGate gate) => + private static VercelAnalyticsClient CreateClient(HttpMessageHandler handler, AnalyticsProviderRequestGate gate) => new(new HttpClient(handler) { BaseAddress = new Uri("https://api.vercel.com/") }, gate); - private static VercelAnalyticsConnection CreateConnection(string? team = null) => new( - Guid.Parse("11111111-1111-1111-1111-111111111110"), "Main", "secret", "project", team, + private static AnalyticsConnection CreateConnection(string? team = null) => new( + Guid.Parse("11111111-1111-1111-1111-111111111110"), "Main", AnalyticsProvider.Vercel, "secret", "project", team, string.Empty, + [], [Guid.Parse("11111111-1111-1111-1111-111111111111")], false, new HashSet(), diff --git a/tests/TheBuilder.WebAnalytics.Tests/TheBuilder.WebAnalytics.Tests.csproj b/tests/TheBuilder.WebAnalytics.Tests/TheBuilder.WebAnalytics.Tests.csproj index 61c3c33..0f62ed3 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/TheBuilder.WebAnalytics.Tests.csproj +++ b/tests/TheBuilder.WebAnalytics.Tests/TheBuilder.WebAnalytics.Tests.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/umbraco-marketplace-readme.md b/umbraco-marketplace-readme.md index 1d61e19..509c1cc 100644 --- a/umbraco-marketplace-readme.md +++ b/umbraco-marketplace-readme.md @@ -1,14 +1,17 @@ ![Web Analytics overview in the Umbraco backoffice](https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/analytics-overview.png) -Web Analytics brings [**Vercel Web Analytics**](https://vercel.com/docs/analytics) into the Umbraco backoffice, giving editors useful traffic and content insights without sending them to another tool. +Web Analytics brings analytics from [**Vercel Web Analytics**](https://vercel.com/docs/analytics) and [**Plausible**](https://plausible.io/docs/stats-api) into the Umbraco backoffice, giving editors useful traffic and content insights without sending them to another tool. ## Key features -- **Site-wide analytics:** Follow visitors, page views, traffic trends, audience, referrers, campaigns, custom events, and feature flags. +- **Site-wide analytics:** Follow visitors, page views, traffic trends, audience, referrers, campaigns, and provider-supported activity. - **Page-level insight:** Open analytics on a published document to see data automatically filtered to that page's route. - **Useful comparisons:** Compare date ranges and drill into the audience, content, and activity behind the headline numbers. -- **Multi-site support:** Connect multiple Umbraco sites to their Vercel projects and bring them together in one Analytics section. +- **Multi-provider and multi-site support:** Connect Umbraco sites to Vercel or Plausible and bring them together in one Analytics section. +- **Capability-aware interface:** Panels and drill-downs adapt to the data available from each provider. Web Analytics fits into the Umbraco permissions and editorial workflows teams already know, making analytics available where content decisions happen. +The Plausible integration uses the Stats API, which requires a Plausible Business plan. + Ready to bring your analytics into the backoffice? See the [installation and configuration guide](https://github.com/thebuilder/web-analytics#readme). diff --git a/umbraco-marketplace.json b/umbraco-marketplace.json index 1ce7f52..80f81c8 100644 --- a/umbraco-marketplace.json +++ b/umbraco-marketplace.json @@ -9,7 +9,7 @@ }, "Category": "Analytics & Insights", "Title": "Web Analytics", - "Description": "Vercel Web Analytics inside the Umbraco backoffice.", + "Description": "Connect web analytics providers and bring site-wide and page-level insights into the Umbraco backoffice.", "DocumentationUrl": "https://github.com/thebuilder/web-analytics#readme", "IssueTrackerUrl": "https://github.com/thebuilder/web-analytics/issues", "LicenseTypes": [ @@ -22,16 +22,16 @@ "Caption": "Explore visitors, page views, traffic history, popular pages, and referrers from the Analytics section." }, { - "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/audience-events-and-flags.png", - "Caption": "Understand audience devices and locations alongside custom events and feature flags." + "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/settings.png", + "Caption": "Set up Vercel and Plausible connections, confirm shared credentials, and configure reporting from the Settings section." }, { - "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/custom-event-details.png", - "Caption": "Drill into custom event properties and compare visitors with total events." + "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/audience-events-and-flags.png", + "Caption": "Understand audience devices and locations alongside provider-supported events and features." }, { - "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/settings.png", - "Caption": "Configure access, caching, project connections, and document analytics mappings in Umbraco." + "ImageUrl": "https://raw.githubusercontent.com/thebuilder/web-analytics/refs/heads/main/docs/screenshots/custom-event-details.png", + "Caption": "Drill into event activity and compare visitors with total events when supported by the provider." } ], "Tags": [ @@ -39,6 +39,9 @@ "web analytics", "Vercel", "Vercel Web Analytics", + "Plausible", + "Plausible Analytics", + "multi-provider", "traffic analytics", "website traffic", "visitors",
Custom events
EventsVisitorsTotal events
Events and goals
Events and goalsVisitorsTotal
0 ? "4px" : "0px"}`}> - + ${this.detailsEnabled + ? html`` + : html`${row.eventName}`} - + ` : ""} ${this.localize.number(row.visitors)} ${this.localize.number(row.count)}