Skip to content

Commit 5703aa9

Browse files
authored
Add multi-provider analytics with Plausible (#34)
* feat: add Plausible analytics provider * refactor: centralize analytics provider contracts * fix: enforce analytics provider capabilities * fix: preserve Plausible report semantics * fix: close multi-provider quality review findings * feat: reshape analytics provider settings * fix: harden analytics settings interactions * fix: order analytics breakdowns by selected metric * fix: mark ongoing analytics periods * feat: add provider-neutral event drilldown * fix: align analytics settings with Umbraco tabs * fix: use local provider brand assets * refactor: make analytics event capabilities explicit * refactor: centralize provider metadata in adapters * fix: publish nullable mock scenarios in OpenAPI * fix: refresh only breakdowns on metric changes * refactor: remove Vercel-default test constructors * refactor: rename shared analytics UI identity * refactor: isolate provider definitions from clients * docs: make provider guidance extensible * docs: clarify provider requirements and credentials * docs: link Plausible Stats API setup * docs: simplify Vercel credential setup * fix: restore Plausible referrer favicons * fix: derive Plausible site base URL * feat: configure Plausible event properties * feat: discover Plausible event properties * fix: polish analytics settings actions * fix: simplify event details dialog * fix: refine floating settings actions * fix: stabilize event details layout * fix: improve empty event details state * fix: enforce analytics provider capabilities * docs: simplify client generation guidance * fix: guard event property exploration by capability * test: characterize asymmetric analytics providers * refactor: centralize provider connection metadata * refactor: register analytics providers from definitions * refactor: make provider catalog the registration source * feat: expose safe analytics provider descriptors * refactor: render settings from provider descriptors * fix: normalize generated client whitespace * docs: align product intent with analytics providers * refactor: separate provider registration from metadata * refactor: derive capabilities from provider clients * refactor: delegate fallback urls to providers * fix: make event property empty state provider neutral * refactor: centralize provider capability metadata * docs: refresh marketplace settings screenshot * fix: align floating settings actions with content * fix: simplify event property guidance * fix: distinguish floating settings actions * refactor: remove redundant provider overview * docs: refresh marketplace settings screenshot
1 parent 84a9abd commit 5703aa9

112 files changed

Lines changed: 5342 additions & 2138 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎DESIGN.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: Web Analytics
3-
description: A native editorial instrument for clear Vercel traffic insight inside Umbraco.
3+
description: A native editorial instrument for clear web analytics insight inside Umbraco.
44
colors:
55
backoffice-ink: "#060606"
66
backoffice-ink-muted: "#2e2b29"
@@ -88,9 +88,9 @@ components:
8888

8989
**Creative North Star: "The Editorial Instrument"**
9090

91-
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.
91+
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.
9292

93-
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.
93+
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.
9494

9595
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.
9696

‎PRODUCT.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ The primary users are content editors and marketing staff working in the authent
1414

1515
## Product Purpose
1616

17-
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.
17+
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.
1818

19-
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.
19+
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.
20+
21+
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.
2022

2123
## Positioning
2224

23-
Vercel traffic insights where editors already work, without exposing credentials or requiring another analytics dashboard.
25+
Clear site-wide and page-level insight where editors already work, without exposing credentials or requiring another analytics dashboard.
2426

2527
## Brand Personality
2628

‎README.md‎

Lines changed: 64 additions & 51 deletions
Large diffs are not rendered by default.

‎docs/screenshots/settings.png‎

-29.6 KB
Loading

samples/TheBuilder.WebAnalytics.Example/VercelAnalyticsOpenApiComposer.cs renamed to samples/TheBuilder.WebAnalytics.Example/WebAnalyticsOpenApiComposer.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Extensions.Options;
1010
using Microsoft.OpenApi;
1111
using Swashbuckle.AspNetCore.SwaggerGen;
12+
using System.Text.Json;
1213
using Umbraco.Cms.Api.Common.OpenApi;
1314
using Umbraco.Cms.Api.Management.OpenApi;
1415
#endif
@@ -18,7 +19,7 @@
1819

1920
namespace TheBuilder.WebAnalytics.Example;
2021

21-
public sealed class VercelAnalyticsOpenApiComposer : IComposer
22+
public sealed class WebAnalyticsOpenApiComposer : IComposer
2223
{
2324
public void Compose(IUmbracoBuilder builder)
2425
{
@@ -42,7 +43,7 @@ public void Compose(IUmbracoBuilder builder)
4243
return Task.CompletedTask;
4344
})));
4445
#else
45-
builder.Services.AddSingleton<IOperationIdHandler, VercelAnalyticsOperationIdHandler>();
46+
builder.Services.AddSingleton<IOperationIdHandler, WebAnalyticsOperationIdHandler>();
4647
builder.Services.Configure<SwaggerGenOptions>(options =>
4748
{
4849
options.SwaggerDoc(
@@ -52,18 +53,41 @@ public void Compose(IUmbracoBuilder builder)
5253
Title = "Web Analytics Backoffice API",
5354
Version = "1.0",
5455
});
55-
options.OperationFilter<VercelAnalyticsOperationSecurityFilter>();
56+
options.OperationFilter<WebAnalyticsOperationSecurityFilter>();
57+
options.SchemaFilter<NullableEnumSchemaFilter>();
5658
});
5759
#endif
5860
}
5961

6062
#if !UMBRACO_18_OR_LATER
61-
private sealed class VercelAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
63+
private sealed class WebAnalyticsOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
6264
{
6365
protected override string ApiName => Constants.ApiName;
6466
}
6567

66-
private sealed class VercelAnalyticsOperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
68+
private sealed class NullableEnumSchemaFilter : ISchemaFilter
69+
{
70+
public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
71+
{
72+
foreach (var property in context.Type.GetProperties())
73+
{
74+
if (Nullable.GetUnderlyingType(property.PropertyType)?.IsEnum is not true)
75+
continue;
76+
77+
var propertyName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
78+
schema.Required?.Remove(propertyName);
79+
if (schema.Properties?.TryGetValue(propertyName, out var propertySchema) is true)
80+
{
81+
schema.Properties[propertyName] = new OpenApiSchema
82+
{
83+
AnyOf = [propertySchema, new OpenApiSchema { Type = JsonSchemaType.Null }]
84+
};
85+
}
86+
}
87+
}
88+
}
89+
90+
private sealed class WebAnalyticsOperationIdHandler(IOptions<ApiVersioningOptions> apiVersioningOptions)
6791
: OperationIdHandler(apiVersioningOptions)
6892
{
6993
protected override bool CanHandle(
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading

‎src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.js‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fetch from 'node-fetch';
22
import chalk from 'chalk';
33
import { createClient, defaultPlugins } from '@hey-api/openapi-ts';
4+
import { readdir, readFile, writeFile } from 'node:fs/promises';
45
import { pathToFileURL } from 'node:url';
56

67
const getSwaggerUrl = (argumentsToParse) => argumentsToParse.find((argument) => argument !== '--');
@@ -24,10 +25,23 @@ const printGenerationError = (error, message) => {
2425
error('Review the generator error and the client configuration in generate-openapi.js.');
2526
};
2627

28+
export async function normalizeGeneratedClient(output = 'src/api') {
29+
const files = await readdir(output);
30+
await Promise.all(files
31+
.filter((file) => file.endsWith('.ts'))
32+
.map(async (file) => {
33+
const path = `${output}/${file}`;
34+
const source = await readFile(path, 'utf8');
35+
const normalized = source.replace(/[\t ]+(?=\r?$)/gm, '');
36+
if (normalized !== source) await writeFile(path, normalized);
37+
}));
38+
}
39+
2740
export async function generateOpenApiClient({
2841
swaggerUrl = getSwaggerUrl(process.argv.slice(2)),
2942
fetchImplementation = fetch,
3043
createClientImplementation = createClient,
44+
normalizeGeneratedOutput = normalizeGeneratedClient,
3145
log = console.log,
3246
error = console.error,
3347
} = {}) {
@@ -61,19 +75,21 @@ export async function generateOpenApiClient({
6175

6276
log(`Calling ${chalk.yellow('hey-api')} to generate TypeScript client`);
6377
try {
78+
const openApiDocument = await response.json();
6479
await createClientImplementation({
65-
input: swaggerUrl,
80+
input: openApiDocument,
6681
output: 'src/api',
6782
plugins: [
6883
...defaultPlugins,
6984
'@hey-api/client-fetch',
7085
{
7186
name: '@hey-api/sdk',
7287
asClass: true,
73-
classNameBuilder: '{{name}}Service',
88+
classNameBuilder: 'WebAnalyticsService',
7489
},
7590
],
7691
});
92+
await normalizeGeneratedOutput();
7793
log('OpenAPI client generated successfully');
7894
return true;
7995
} catch (exception) {

‎src/TheBuilder.WebAnalytics/Client/scripts/generate-openapi.test.js‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { spawn } from 'node:child_process';
2+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
23
import { createServer } from 'node:http';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
36
import { fileURLToPath } from 'node:url';
47
import { afterEach, describe, expect, it } from 'vitest';
5-
import { generateOpenApiClient } from './generate-openapi.js';
8+
import { generateOpenApiClient, normalizeGeneratedClient } from './generate-openapi.js';
69

710
const scriptPath = fileURLToPath(new URL('./generate-openapi.js', import.meta.url));
811

@@ -86,7 +89,7 @@ describe('generate-openapi', () => {
8689
const errors = [];
8790
const succeeded = await generateOpenApiClient({
8891
swaggerUrl: 'http://localhost/openapi.json',
89-
fetchImplementation: async () => ({ ok: true }),
92+
fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }),
9093
createClientImplementation: async () => {
9194
throw new Error('generation failed');
9295
},
@@ -104,12 +107,16 @@ describe('generate-openapi', () => {
104107
it('does not report success until the client generator resolves', async () => {
105108
const logs = [];
106109
let resolveGeneration;
110+
let normalized = false;
107111
const generation = generateOpenApiClient({
108112
swaggerUrl: 'http://localhost/openapi.json',
109-
fetchImplementation: async () => ({ ok: true }),
113+
fetchImplementation: async () => ({ ok: true, json: async () => ({ openapi: '3.0.0', paths: {} }) }),
110114
createClientImplementation: () => new Promise((resolve) => {
111115
resolveGeneration = resolve;
112116
}),
117+
normalizeGeneratedOutput: async () => {
118+
normalized = true;
119+
},
113120
log: (message) => logs.push(message),
114121
error: () => {},
115122
});
@@ -121,6 +128,22 @@ describe('generate-openapi', () => {
121128
resolveGeneration();
122129

123130
await expect(generation).resolves.toBe(true);
131+
expect(normalized).toBe(true);
124132
expect(logs).toContain('OpenAPI client generated successfully');
125133
});
134+
135+
it('normalizes trailing whitespace from generated TypeScript files', async () => {
136+
const output = await mkdtemp(join(tmpdir(), 'web-analytics-openapi-'));
137+
const generatedFile = join(output, 'sdk.gen.ts');
138+
try {
139+
await writeFile(generatedFile, 'export const generated = true; \r\n \r\n');
140+
141+
await normalizeGeneratedClient(output);
142+
143+
await expect(readFile(generatedFile, 'utf8')).resolves.toBe('export const generated = true;\r\n\r\n');
144+
} finally {
145+
await rm(output, { recursive: true, force: true });
146+
}
147+
});
148+
126149
});

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import "./event-table.element.js";
1111
import "./flag-card.element.js";
1212
import { stateData, type AsyncState } from "./async-state.js";
1313

14-
@customElement("vercel-analytics-breakdown-grid")
15-
export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) {
14+
@customElement("web-analytics-breakdown-grid")
15+
export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) {
1616
@property({ attribute: false }) cards: ReadonlyArray<DashboardCard> = [];
1717
@property({ attribute: false }) breakdowns: Partial<Record<AnalyticsDimension, AsyncState<AnalyticsBreakdown>>> = {};
1818
@property({ attribute: false }) events: AsyncState<AnalyticsEventsReport> = { status: "loading" };
@@ -24,6 +24,10 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
2424
@property() acquisitionView: AcquisitionView = "referrers";
2525
@property() utmDimension: UtmDimension = "UtmSource";
2626
@property() baseUrl?: string;
27+
@property({ type: Boolean }) supportsEvents = true;
28+
@property({ type: Boolean }) supportsGlobalEventFiltering = false;
29+
@property({ type: Boolean }) supportsEventDetails = true;
30+
@property({ type: Boolean }) supportsFlags = true;
2731

2832
#dispatch(name: string, detail?: unknown): void {
2933
this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
@@ -112,7 +116,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
112116
return html`
113117
<uui-box class=${`breakdown-card ${card.span === "wide" ? "wide" : ""}`}>
114118
<div class="breakdown-card-layout">
115-
<vercel-analytics-breakdown-table
119+
<web-analytics-breakdown-table
116120
.headline=${selected.headline}
117121
.dimension=${selected.dimension}
118122
.metric=${this.metric}
@@ -124,8 +128,8 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
124128
.linkValues=${linkValues}
125129
.unavailable=${unavailable}>
126130
${card.kind === "tabbed-breakdown" ? this.#renderTabs(card) : ""}
127-
</vercel-analytics-breakdown-table>
128-
${planLimited && unavailable ? html`<p class="hint breakdown-hint">UTM reporting availability depends on your Vercel plan and reporting window.</p>` : ""}
131+
</web-analytics-breakdown-table>
132+
${planLimited && unavailable ? html`<p class="hint breakdown-hint">UTM reporting availability depends on your analytics plan and reporting window.</p>` : ""}
129133
<footer class="breakdown-footer">
130134
${!loading && !unavailable && rows.length ? html`
131135
<uui-button look="secondary" label=${`View all ${selected.headline}`} @click=${() => this.#dispatch("view-breakdown", selected)}>View all</uui-button>
@@ -154,7 +158,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
154158
return html`
155159
<uui-box class="breakdown-card wide">
156160
<div class="breakdown-card-layout">
157-
<vercel-analytics-breakdown-table
161+
<web-analytics-breakdown-table
158162
.headline=${selected.headline}
159163
.dimension=${selected.dimension}
160164
.metric=${this.metric}
@@ -167,7 +171,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
167171
.unavailable=${unavailable}>
168172
${this.#renderAcquisitionTabs(utmAvailable)}
169173
${showingUtm ? this.#renderUtmTabs(utmCard) : ""}
170-
</vercel-analytics-breakdown-table>
174+
</web-analytics-breakdown-table>
171175
<footer class="breakdown-footer">
172176
${!loading && !unavailable && rows.length ? html`
173177
<uui-button look="secondary" label=${`View all ${selected.headline}`} @click=${() => this.#dispatch("view-breakdown", selected)}>View all</uui-button>
@@ -187,7 +191,7 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
187191
return html`
188192
<uui-box class="breakdown-card wide">
189193
<div class=${`breakdown-card-layout${empty ? " empty-card-layout" : ""}`}>
190-
<vercel-analytics-event-table .rows=${rows} .filters=${this.filters} .loading=${loading}></vercel-analytics-event-table>
194+
<web-analytics-event-table .rows=${rows} .filters=${this.filters} .loading=${loading} .detailsEnabled=${this.supportsEventDetails} .filteringEnabled=${this.supportsGlobalEventFiltering}></web-analytics-event-table>
191195
${empty ? "" : html`<footer class="breakdown-footer">
192196
${!loading && rows.length ? html`<uui-button look="secondary" label="View all events" @click=${() => this.#dispatch("view-events")}>View all</uui-button>` : ""}
193197
</footer>`}
@@ -199,19 +203,19 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
199203
render() {
200204
const standardCards = this.cards.filter((card) => card.kind !== "tabbed-breakdown" || card.id !== "utm");
201205
const utmCard = this.cards.find((card): card is Extract<DashboardCard, { kind: "tabbed-breakdown" }> => card.kind === "tabbed-breakdown" && card.id === "utm");
202-
const referrerCard = standardCards.find((card) => card.kind === "breakdown" && card.dimension === "ReferrerHostname");
206+
const referrerCard = standardCards.find((card) => card.kind === "breakdown" && (card.dimension === "ReferrerHostname" || card.dimension === "Referrer"));
203207
const renderCard = (card: DashboardCard) => card === referrerCard ? this.#renderAcquisitionCard(card, utmCard) : this.#renderCard(card);
204208
const documentScoped = !standardCards.some((card) => card.kind === "breakdown" && card.dimension === "RequestPath");
205209
const cardsBeforeEvents = documentScoped ? standardCards.slice(0, 1) : standardCards;
206210
const cardsAfterEvents = documentScoped ? standardCards.slice(1) : [];
207211
return html`
208212
<section class="grid" aria-label="Traffic breakdowns">
209213
${cardsBeforeEvents.map(renderCard)}
210-
${this.#renderEvents()}
214+
${this.supportsEvents ? this.#renderEvents() : ""}
211215
${cardsAfterEvents.map(renderCard)}
212-
<uui-box class=${`breakdown-card flags-card${documentScoped ? " document-flags-card" : " wide"}`}>
213-
<vercel-analytics-flag-card .report=${this.flags} .selected=${this.selectedFlag}></vercel-analytics-flag-card>
214-
</uui-box>
216+
${this.supportsFlags ? html`<uui-box class=${`breakdown-card flags-card${documentScoped ? " document-flags-card" : " wide"}`}>
217+
<web-analytics-flag-card .report=${this.flags} .selected=${this.selectedFlag}></web-analytics-flag-card>
218+
</uui-box>` : ""}
215219
</section>
216220
`;
217221
}
@@ -249,4 +253,4 @@ export class VercelAnalyticsBreakdownGridElement extends UmbElementMixin(LitElem
249253
`];
250254
}
251255

252-
declare global { interface HTMLElementTagNameMap { "vercel-analytics-breakdown-grid": VercelAnalyticsBreakdownGridElement; } }
256+
declare global { interface HTMLElementTagNameMap { "web-analytics-breakdown-grid": WebAnalyticsBreakdownGridElement; } }

0 commit comments

Comments
 (0)