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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,21 @@ For a team-owned project, also copy either the team ID (`team_...`) or team slug
Configure one shared token for the package:

```text
VercelAnalytics__AccessToken
WebAnalytics__Providers__Vercel__AccessToken
```

Examples:

```sh
# Local shell or container environment
export VercelAnalytics__AccessToken="your_token"
export WebAnalytics__Providers__Vercel__AccessToken="your_token"

# .NET user-secrets
dotnet user-secrets init \
--project path/to/Your.Umbraco.Web.csproj

dotnet user-secrets set \
"VercelAnalytics:AccessToken" \
"WebAnalytics:Providers:Vercel:AccessToken" \
"your_token" \
--project path/to/Your.Umbraco.Web.csproj
```
Expand All @@ -78,7 +78,7 @@ Use the equivalent secret/app-setting facility in Azure App Service, Kubernetes,

Restart every Umbraco application instance after adding or rotating a token. Tokens are loaded from server configuration at application startup.

The shared token is used by every connection. If a project must use a different token, expand **Token override** for that connection and copy the generated environment-variable name. Overrides use `VercelAnalytics__ConnectionAccessTokens__{connection-guid}`.
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}`.

### 3. Configure the connection in Umbraco

Expand Down Expand Up @@ -121,7 +121,7 @@ The backoffice settings screen is the normal configuration path. A deployment ca

```json
{
"VercelAnalytics": {
"WebAnalytics": {
"Enabled": true,
"DefaultRangeDays": 30,
"CacheDuration": "00:05:00",
Expand All @@ -147,12 +147,12 @@ The backoffice settings screen is the normal configuration path. A deployment ca

### Configuration reference

Package settings use the `VercelAnalytics` section.
Package settings use the `WebAnalytics` section.

| Key | Default | Description |
| --- | --- | --- |
| `Enabled` | `false` | Enables the Analytics section and configured document workspace views. |
| `AccessToken` | Empty | Shared Vercel access token used by every connection. Supply through secret configuration. |
| `Providers:Vercel:AccessToken` | Empty | Shared Vercel access token used by every connection. Supply through secret configuration. |
| `DefaultRangeDays` | `30` | Initial report range in days. Valid values are 1–730. |
| `CacheDuration` | `00:05:00` | Per-instance in-memory cache duration. Valid values are zero to one hour. |
| `Connections` | `[]` | Vercel project connection definitions. The first connection becomes the initial default. |
Expand All @@ -171,7 +171,7 @@ Each entry under `Connections` supports:
| `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 `VercelAnalytics__AccessToken`; only use `VercelAnalytics__ConnectionAccessTokens__{connection-guid}` when one connection requires an override.
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.

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.

Expand All @@ -195,7 +195,7 @@ The available reporting window and some dimensions depend on the Vercel plan and

| Symptom | Check |
| --- | --- |
| **Token missing** | Configure `VercelAnalytics__AccessToken`, or a connection-specific override, and restart the application. |
| **Token missing** | Configure `WebAnalytics__Providers__Vercel__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. |
| Analytics section is not visible | Add the Analytics section to the user's Umbraco user group. The automatic administrator grant runs only once. |
Expand All @@ -205,7 +205,7 @@ The available reporting window and some dimensions depend on the Vercel plan and

## Development

The example app opts into development data through `VercelAnalytics: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. 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 client uses pnpm 11. From `src/TheBuilder.WebAnalytics/Client`:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
}
]
},
"VercelAnalytics": {
"WebAnalytics": {
"EnableMockConnections": true
},
"Umbraco": {
Expand Down
4 changes: 2 additions & 2 deletions samples/TheBuilder.WebAnalytics.Example/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
}
}
},
"VercelAnalytics": {
"WebAnalytics": {
"Enabled": true,
"DefaultRangeDays": 30,
"CacheDuration": "00:05:00",
Expand All @@ -43,4 +43,4 @@
"umbracoDbDSN": "Data Source=|DataDirectory|/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True",
"umbracoDbDSN_ProviderName": "Microsoft.Data.Sqlite"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE
}

async #copyTokenKey(): Promise<void> {
await navigator.clipboard.writeText(`VercelAnalytics__ConnectionAccessTokens__${this.connection.key}`);
await navigator.clipboard.writeText(`WebAnalytics__ConnectionAccessTokens__${this.connection.key}`);
this._tokenCopied = true;
window.setTimeout(() => { this._tokenCopied = false; }, 2000);
}
Expand Down Expand Up @@ -164,7 +164,7 @@ export class VercelAnalyticsConnectionEditorElement extends UmbElementMixin(LitE
Create a Vercel access token<uui-icon name="icon-out" aria-hidden="true"></uui-icon>
</a>
</p>
<div class="token-key"><code>VercelAnalytics__ConnectionAccessTokens__${connection.key}</code><uui-button compact look="secondary" label="Copy access token setting name" @click=${this.#copyTokenKey}>${this._tokenCopied ? "Copied" : "Copy"}</uui-button></div>
<div class="token-key"><code>WebAnalytics__ConnectionAccessTokens__${connection.key}</code><uui-button compact look="secondary" label="Copy access token setting name" @click=${this.#copyTokenKey}>${this._tokenCopied ? "Copied" : "Copy"}</uui-button></div>
</div>
</details>`}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit
}

async #copyTokenKey(): Promise<void> {
await navigator.clipboard.writeText("VercelAnalytics__AccessToken");
await navigator.clipboard.writeText("WebAnalytics__Providers__Vercel__AccessToken");
this._tokenKeyCopied = true;
window.setTimeout(() => { this._tokenKeyCopied = false; }, 2000);
}
Expand Down Expand Up @@ -267,7 +267,7 @@ export class VercelAnalyticsSettingsDashboardElement extends UmbElementMixin(Lit
<a href="https://vercel.com/account/settings/tokens" target="_blank" rel="noopener noreferrer" aria-label="Create a Vercel access token (opens in a new tab)">Create token<uui-icon name="icon-out" aria-hidden="true"></uui-icon></a>
</div>
<div class="shared-token-key">
<code>VercelAnalytics__AccessToken</code>
<code>WebAnalytics__Providers__Vercel__AccessToken</code>
<uui-button compact look="secondary" label="Copy shared access token setting name" @click=${this.#copyTokenKey}>${this._tokenKeyCopied ? "Copied" : "Copy"}</uui-button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe("analytics settings onboarding", () => {
const generatedKey = (editor as VercelAnalyticsConnectionEditorElement).connection.key;
expect(generatedKey).toMatch(/^[0-9a-f-]{36}$/i);
expect(editor?.shadowRoot?.querySelector(".token-key code")?.textContent)
.toBe(`VercelAnalytics__ConnectionAccessTokens__${generatedKey}`);
.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?.querySelectorAll('[label="Save Web Analytics settings"]')).toHaveLength(1);
Expand All @@ -182,7 +182,7 @@ describe("analytics settings onboarding", () => {
document.body.append(dashboard);
await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".shared-token")).not.toBeNull());

expect(dashboard.shadowRoot?.querySelector(".shared-token code")?.textContent).toBe("VercelAnalytics__AccessToken");
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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ internal RegistrySnapshot Capture()
private RegistrySnapshot CreateSnapshot(VercelAnalyticsSettingsSnapshot 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,
ResolveAccessToken(
serverConfiguration.AccessToken,
vercelConfiguration.AccessToken,
serverConfiguration.ConnectionAccessTokens.GetValueOrDefault(connection.Key.ToString()))));
var roots = connections.Values
.SelectMany(connection => connection.DocumentRootKeys.Select(rootKey => (rootKey, connection.Key)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ namespace TheBuilder.WebAnalytics.Configuration;

public sealed class VercelAnalyticsOptions
{
public const string SectionName = "VercelAnalytics";
public const string SectionName = "WebAnalytics";

public bool Enabled { get; set; } = true;

public bool EnableMockConnections { get; set; }

public string AccessToken { get; set; } = string.Empty;

public int DefaultRangeDays { get; set; } = 30;

public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
Expand All @@ -20,6 +18,18 @@ public sealed class VercelAnalyticsOptions

public Dictionary<string, string> ConnectionAccessTokens { get; set; } =
new(StringComparer.OrdinalIgnoreCase);

public WebAnalyticsProvidersOptions Providers { get; set; } = new();
}

public sealed class WebAnalyticsProvidersOptions
{
public VercelAnalyticsProviderOptions Vercel { get; set; } = new();
}

public sealed class VercelAnalyticsProviderOptions
{
public string AccessToken { get; set; } = string.Empty;
}

public sealed class VercelAnalyticsConnectionOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ private async Task<AnalyticsSettingsResponse> 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 =>
{
Expand All @@ -140,7 +141,7 @@ private async Task<AnalyticsSettingsResponse> CreateResponseAsync(CancellationTo
var responseConnections = await Task.WhenAll(responseTasks);
return new AnalyticsSettingsResponse(
settings.Enabled,
!string.IsNullOrWhiteSpace(serverConfiguration.AccessToken),
!string.IsNullOrWhiteSpace(vercelConfiguration.AccessToken),
registry.MockConnectionsEnabled,
settings.DefaultRangeDays,
settings.CacheDuration.ToString("c"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public void Shared_token_is_valid_before_ui_setup()
var options = new VercelAnalyticsOptions
{
Enabled = true,
AccessToken = "server-secret"
Providers = { Vercel = { AccessToken = "server-secret" } }
};

Assert.True(_sut.Validate(null, options).Succeeded);
Expand Down Expand Up @@ -98,7 +98,7 @@ public void Registry_does_not_throw_when_invalid_keys_bypass_startup_validation(
public void Registry_uses_shared_access_token_when_connection_has_no_override()
{
var options = CreateOptions();
options.AccessToken = "shared-token";
options.Providers.Vercel.AccessToken = "shared-token";

var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey);

Expand All @@ -111,7 +111,7 @@ public void Registry_uses_shared_access_token_when_connection_has_no_override()
public void Registry_prefers_connection_access_token_override()
{
var options = CreateOptions();
options.AccessToken = "shared-token";
options.Providers.Vercel.AccessToken = "shared-token";
options.ConnectionAccessTokens[MainKey.ToString()] = "connection-token";

var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey);
Expand All @@ -136,7 +136,7 @@ public void Connection_string_representation_redacts_access_token()
public void Registry_is_unconfigured_when_shared_and_connection_tokens_are_missing()
{
var options = CreateOptions();
options.AccessToken = string.Empty;
options.Providers.Vercel.AccessToken = string.Empty;

var connection = new VercelAnalyticsConnectionRegistry(Options.Create(options)).Get(MainKey);

Expand Down Expand Up @@ -210,7 +210,7 @@ public void Registry_supports_explicit_and_all_document_type_modes()
private static VercelAnalyticsOptions CreateOptions() => new()
{
Enabled = true,
AccessToken = "test-token",
Providers = { Vercel = { AccessToken = "test-token" } },
Connections = [CreateConnection(MainKey)]
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ public void Store_preserves_connection_order_from_server_options()
var options = new VercelAnalyticsOptions
{
Enabled = true,
AccessToken = "server-secret",
Providers = { Vercel = { AccessToken = "server-secret" } },
Connections =
[
new() { Key = MainKey, ProjectId = "first-project" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ private static VercelAnalyticsConnectionRegistry EnabledRegistry(params string[]
new(Options.Create(new VercelAnalyticsOptions
{
Enabled = true,
AccessToken = "secret",
Providers = { Vercel = { AccessToken = "secret" } },
Connections = (aliases.Length == 0 ? ["main"] : aliases).Select(
alias => new VercelAnalyticsConnectionOptions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ private static VercelAnalyticsConnectionRegistry CreateRegistry(
params VercelAnalyticsConnectionOptions[] connections) => new(Options.Create(new VercelAnalyticsOptions
{
Enabled = true,
AccessToken = "secret",
Providers = { Vercel = { AccessToken = "secret" } },
Connections = connections.ToList()
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ public async Task Cancelling_one_summary_waiter_does_not_cancel_the_shared_clien
private static VercelAnalyticsConnectionRegistry CreateRegistry(TimeSpan? cacheDuration = null) => new(Options.Create(new VercelAnalyticsOptions
{
Enabled = true,
AccessToken = "secret",
Providers = { Vercel = { AccessToken = "secret" } },
CacheDuration = cacheDuration ?? TimeSpan.FromMinutes(5),
Connections =
[
Expand Down