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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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 \
Expand Down Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ internal static AnalyticsProviderRegistration Create<TClient>(
Uri baseAddress)
where TClient : class, IAnalyticsProviderClient =>
new(definition, services => services.AddAnalyticsProvider<TClient>(baseAddress));

internal static AnalyticsProviderRegistration Create<TClient>(
AnalyticsProviderDefinition definition,
Func<WebAnalyticsOptions, Uri> baseAddress)
where TClient : class, IAnalyticsProviderClient =>
new(definition, services => services.AddAnalyticsProvider<TClient>(baseAddress));
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TheBuilder.WebAnalytics.Services;

namespace TheBuilder.WebAnalytics.Configuration;
Expand All @@ -19,4 +20,19 @@ internal static IServiceCollection AddAnalyticsProvider<TClient>(
serviceProvider.GetRequiredService<TClient>());
return services;
}

internal static IServiceCollection AddAnalyticsProvider<TClient>(
this IServiceCollection services,
Func<WebAnalyticsOptions, Uri> baseAddress)
where TClient : class, IAnalyticsProviderClient
{
services.AddHttpClient<TClient>((serviceProvider, client) =>
{
client.BaseAddress = baseAddress(serviceProvider.GetRequiredService<IOptions<WebAnalyticsOptions>>().Value);
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddTransient<IAnalyticsProviderClient>(serviceProvider =>
serviceProvider.GetRequiredService<TClient>());
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using TheBuilder.WebAnalytics.Providers;

namespace TheBuilder.WebAnalytics.Configuration;

Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,33 @@ internal static class PlausibleProvider
fallbackBaseUrl: GetSiteBaseUrl);

internal static AnalyticsProviderRegistration Registration { get; } =
AnalyticsProviderRegistration.Create<PlausibleAnalyticsClient>(Definition, new Uri("https://plausible.io/"));
AnalyticsProviderRegistration.Create<PlausibleAnalyticsClient>(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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down