diff --git a/README.md b/README.md index 8a7987a..3edb039 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The connection and reporting workflow is provider-neutral. Availability of indiv | [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. +Plausible Cloud's Stats API requires a Business plan. Self-hosted Plausible is supported when its instance exposes the v2 Stats API. ## Install @@ -58,7 +58,7 @@ Create a token in the account settings and scope it to the account or team that #### Plausible -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. +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. For a self-hosted instance, also set its public base URL in `WebAnalytics:Providers:Plausible:BaseUrl`. ### 2. Add the credential to the Umbraco deployment @@ -67,6 +67,7 @@ Configure a shared credential for each provider you use: ```text WebAnalytics__Providers__Vercel__AccessToken WebAnalytics__Providers__Plausible__AccessToken +WebAnalytics__Providers__Plausible__BaseUrl ``` Examples: @@ -75,6 +76,7 @@ Examples: # Local shell or container environment export WebAnalytics__Providers__Vercel__AccessToken="your_token" export WebAnalytics__Providers__Plausible__AccessToken="your_stats_api_key" +export WebAnalytics__Providers__Plausible__BaseUrl="https://analytics.example.com/" # .NET user-secrets dotnet user-secrets init \ @@ -170,14 +172,15 @@ Package settings use the `WebAnalytics` section. | `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 configuration -Provider credentials are shared by every connection using that provider unless a connection-specific override is configured. +Provider credentials are shared by every connection using that provider unless a connection-specific override is configured. Plausible's base URL applies to every Plausible connection. -| 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. | +| Setting | Configuration key | Default | Description | +| --- | --- | --- | --- | +| Vercel token | `Providers:Vercel:AccessToken` | Empty | Scoped access token for the account or team that owns the configured projects. | +| Plausible token | `Providers:Plausible:AccessToken` | Empty | Stats API key. Plausible Cloud requires a Business plan. | +| Plausible base URL | `Providers:Plausible:BaseUrl` | `https://plausible.io/` | Base URL of the Plausible Cloud or self-hosted instance. It must expose `/api/v2/query`. | Each entry under `Connections` supports: diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs index 3ac7221..685cfd0 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderRegistration.cs @@ -16,4 +16,10 @@ internal static AnalyticsProviderRegistration Create( Uri baseAddress) where TClient : class, IAnalyticsProviderClient => new(definition, services => services.AddAnalyticsProvider(baseAddress)); + + internal static AnalyticsProviderRegistration Create( + AnalyticsProviderDefinition definition, + Func 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 index 1012f68..58a633f 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderServiceCollectionExtensions.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using TheBuilder.WebAnalytics.Services; namespace TheBuilder.WebAnalytics.Configuration; @@ -19,4 +20,19 @@ internal static IServiceCollection AddAnalyticsProvider( serviceProvider.GetRequiredService()); return services; } + + internal static IServiceCollection AddAnalyticsProvider( + this IServiceCollection services, + Func baseAddress) + where TClient : class, IAnalyticsProviderClient + { + services.AddHttpClient((serviceProvider, client) => + { + client.BaseAddress = baseAddress(serviceProvider.GetRequiredService>().Value); + client.Timeout = TimeSpan.FromSeconds(15); + }); + services.AddTransient(serviceProvider => + serviceProvider.GetRequiredService()); + return services; + } } diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs index 4e4c35a..193d2c1 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs @@ -36,7 +36,11 @@ public sealed class VercelAnalyticsProviderOptions public sealed class PlausibleAnalyticsProviderOptions { + public const string DefaultBaseUrl = "https://plausible.io/"; + public string AccessToken { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = DefaultBaseUrl; } public sealed class AnalyticsConnectionOptions diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs index 81a71e0..58a9a2b 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptionsValidator.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using TheBuilder.WebAnalytics.Providers; namespace TheBuilder.WebAnalytics.Configuration; @@ -9,7 +10,11 @@ public ValidateOptionsResult Validate(string? name, WebAnalyticsOptions options) var settings = WebAnalyticsSettingsMapper.FromServerOptions(options); var failures = WebAnalyticsSettingsValidator.Validate( settings, - WebAnalyticsValidationMode.ServerOptions); + WebAnalyticsValidationMode.ServerOptions).ToList(); + if (!PlausibleProvider.TryGetApiBaseUrl(options.Providers.Plausible.BaseUrl, out _)) + { + failures.Add("WebAnalytics:Providers:Plausible:BaseUrl must be an absolute HTTP or HTTPS URL without a query, fragment, or user information."); + } return failures.Count == 0 ? ValidateOptionsResult.Success diff --git a/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs index 61dab90..6a3ef9c 100644 --- a/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs +++ b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs @@ -42,7 +42,33 @@ internal static class PlausibleProvider fallbackBaseUrl: GetSiteBaseUrl); internal static AnalyticsProviderRegistration Registration { get; } = - AnalyticsProviderRegistration.Create(Definition, new Uri("https://plausible.io/")); + AnalyticsProviderRegistration.Create(Definition, GetApiBaseUrl); + + internal static Uri GetApiBaseUrl(WebAnalyticsOptions options) + { + if (TryGetApiBaseUrl(options.Providers.Plausible.BaseUrl, out var baseUrl)) return baseUrl; + + throw new ArgumentException( + "Plausible BaseUrl must be an absolute HTTP or HTTPS URL without a query, fragment, or user information.", + nameof(options)); + } + + internal static bool TryGetApiBaseUrl(string? configuredUrl, out Uri baseUrl) + { + baseUrl = null!; + if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var parsed) || + (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) || + string.IsNullOrWhiteSpace(parsed.Host) || + !string.IsNullOrEmpty(parsed.UserInfo) || + !string.IsNullOrEmpty(parsed.Query) || + !string.IsNullOrEmpty(parsed.Fragment)) + { + return false; + } + + baseUrl = new Uri($"{parsed.GetLeftPart(UriPartial.Path).TrimEnd('/')}/", UriKind.Absolute); + return true; + } private static string? GetSiteBaseUrl(AnalyticsConnection connection) { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs index 31eb805..265e202 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using TheBuilder.WebAnalytics.Configuration; using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Providers; namespace TheBuilder.WebAnalytics.Tests.Configuration; @@ -41,6 +42,34 @@ public void Shared_token_is_valid_before_ui_setup() Assert.True(_sut.Validate(null, options).Succeeded); } + [Fact] + public void Self_hosted_plausible_base_url_is_valid_and_normalized() + { + var options = new WebAnalyticsOptions + { + Providers = { Plausible = { BaseUrl = "https://analytics.example.com/plausible" } } + }; + + Assert.True(_sut.Validate(null, options).Succeeded); + Assert.Equal("https://analytics.example.com/plausible/", PlausibleProvider.GetApiBaseUrl(options).ToString()); + } + + [Theory] + [InlineData("not a URL")] + [InlineData("ftp://analytics.example.com")] + [InlineData("https://analytics.example.com/?key=secret")] + public void Invalid_plausible_base_url_fails_validation(string baseUrl) + { + var options = new WebAnalyticsOptions + { + Providers = { Plausible = { BaseUrl = baseUrl } } + }; + + var result = _sut.Validate(null, options); + + Assert.Contains(result.Failures!, failure => failure.Contains("Providers:Plausible:BaseUrl")); + } + [Fact] public void Valid_configuration_succeeds() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs index c6449a4..96236a7 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/PlausibleAnalyticsClientTests.cs @@ -25,6 +25,19 @@ public async Task Count_posts_authenticated_v2_query_with_exclusive_end_adjusted Assert.Equal("2026-07-02T23:59:59.9999999+00:00", body.RootElement.GetProperty("date_range")[1].GetString()); } + [Fact] + public async Task Count_targets_configured_self_hosted_base_url() + { + var handler = new RecordingHandler("""{"results":[{"dimensions":[],"metrics":[42,31]}]}"""); + var client = new PlausibleAnalyticsClient( + new HttpClient(handler) { BaseAddress = new Uri("https://analytics.example.com/plausible/") }, + new AnalyticsProviderRequestGate()); + + await client.CountAsync(CreateConnection(), CreateQuery(), CancellationToken.None); + + Assert.Equal("https://analytics.example.com/plausible/api/v2/query", handler.Request?.RequestUri?.ToString()); + } + [Fact] public async Task Count_applies_a_global_event_name_filter() {