Skip to content

Commit dcc59ea

Browse files
committed
telemetry
1 parent fa1b5ff commit dcc59ea

File tree

14 files changed

+275
-47
lines changed

14 files changed

+275
-47
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ Rule format:
107107
- Do not add or raise CI timeout settings as a fix for browser-suite instability or slow runs; fix the underlying failure path cleanly instead of masking it with workflow or step timeouts.
108108
- For TUnit reporting in GitHub Actions, prefer TUnit's built-in HTML report and the documented `actions/github-script` runtime exposure step over repo-local custom summary scripts or ad-hoc log parsers.
109109
- Public web hosting is split by role: the standalone PrompterOne app in this repo must publish on `app.prompter.one`, while the marketing landing site for `prompter.one` lives in the separate `PrompterOne-LandingPage` repository.
110+
- Runtime telemetry providers such as Google Analytics, Clarity, and Sentry must not be described as connected or working unless the real production path is verified with actual outbound delivery or loaded vendor SDKs; local harness snapshots, init flags, or stubbed globals are not sufficient proof.
111+
- Runtime telemetry readiness must be proven against Release-built app artifacts in CI; do not sign off GA, Clarity, or Sentry from Debug-only local runs when the shipped Release pipeline has not validated that path.
110112
- For deploy-only, domain, CI, or static-site hosting tasks, do not spend time on unrelated app/browser test suites unless the user explicitly asks or the runtime behavior itself changes; prefer workflow, build, and publish-config validation only.
111113
- Repo-wide .NET SDK and test-runner selection belong in the root `global.json`; do not split `global.json` test-runner opt-ins per project or subfolder once the user asks for a global test-platform policy.
112114
- Browser and component tests must use one selector format only: `data-test`; do not mix in any alternate test-attribute naming variants.

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
2020
<PackageVersion Include="Microsoft.Extensions.Logging.Configuration" Version="10.0.5" />
2121
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
22+
<PackageVersion Include="Sentry.AspNetCore.Blazor.WebAssembly" Version="6.3.0" />
2223
<PackageVersion Include="Sentry" Version="6.3.0" />
2324
<PackageVersion Include="Shouldly" Version="4.3.0" />
2425
<PackageVersion Include="TUnit" Version="1.28.7" />

src/PrompterOne.Shared/AppShell/Services/RuntimeTelemetryOptions.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ namespace PrompterOne.Shared.Services;
33
public sealed record RuntimeTelemetryOptions(
44
string GoogleAnalyticsMeasurementId,
55
string ClarityProjectId,
6-
bool HostEnabled,
7-
bool SentryConfigured)
6+
string SentryDsn,
7+
bool HostEnabled)
88
{
99
public const string ClarityProjectIdKey = "ClarityProjectId";
1010
public const string ConfigurationSectionName = "RuntimeTelemetry";
1111
public const string GoogleAnalyticsMeasurementIdKey = "GoogleAnalyticsMeasurementId";
1212
public const string HostEnabledKey = "HostEnabled";
13+
public const string SentryDsnKey = "SentryDsn";
1314
public const string SectionSeparator = ":";
1415

1516
public static RuntimeTelemetryOptions Disabled { get; } =
16-
new(string.Empty, string.Empty, HostEnabled: false, SentryConfigured: false);
17+
new(string.Empty, string.Empty, string.Empty, HostEnabled: false);
18+
19+
public bool SentryConfigured => !string.IsNullOrWhiteSpace(SentryDsn);
1720

1821
public static string ClarityProjectIdPath =>
1922
string.Concat(ConfigurationSectionName, SectionSeparator, ClarityProjectIdKey);
@@ -23,4 +26,7 @@ public sealed record RuntimeTelemetryOptions(
2326

2427
public static string HostEnabledPath =>
2528
string.Concat(ConfigurationSectionName, SectionSeparator, HostEnabledKey);
29+
30+
public static string SentryDsnPath =>
31+
string.Concat(ConfigurationSectionName, SectionSeparator, SentryDsnKey);
2632
}

src/PrompterOne.Shared/AppShell/Services/RuntimeTelemetryService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ await module.InvokeVoidAsync(
3636
harnessGlobalName = AppRuntimeTelemetry.Harness.GlobalName,
3737
initializationsCollection = AppRuntimeTelemetry.Harness.InitializationsCollection,
3838
pageViewsCollection = AppRuntimeTelemetry.Harness.PageViewsCollection,
39+
runtimeAllowVendorLoadsProperty = AppRuntimeTelemetry.Harness.RuntimeAllowVendorLoadsProperty,
3940
runtimeGlobalName = AppRuntimeTelemetry.Harness.RuntimeGlobalName,
4041
runtimeHarnessEnabledProperty = AppRuntimeTelemetry.Harness.RuntimeHarnessEnabledProperty,
4142
runtimeWasmDebugEnabledProperty = AppRuntimeTelemetry.Harness.RuntimeWasmDebugEnabledProperty,

src/PrompterOne.Shared/Contracts/AppRuntimeTelemetry.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public static class Harness
88
public const string GlobalName = "__prompterOneTelemetryHarness";
99
public const string InitializationsCollection = "initializations";
1010
public const string PageViewsCollection = "pageViews";
11+
public const string RuntimeAllowVendorLoadsProperty = "telemetryAllowVendorLoads";
1112
public const string RuntimeGlobalName = "__prompterOneRuntime";
1213
public const string RuntimeHarnessEnabledProperty = "telemetryHarnessEnabled";
1314
public const string RuntimeWasmDebugEnabledProperty = "wasmDebugEnabled";

src/PrompterOne.Shared/wwwroot/app/runtime-telemetry.js

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
const clarityFunctionName = "clarity";
22
const clarityProviderName = "clarity";
3+
const clarityScriptElementId = "prompterone-runtime-clarity";
4+
const clarityScriptUrlPrefix = "https://www.clarity.ms/tag/";
35
const googleAnalyticsFunctionName = "gtag";
46
const googleAnalyticsProviderName = "google-analytics";
7+
const googleAnalyticsScriptElementId = "prompterone-runtime-google-analytics";
8+
const googleAnalyticsScriptUrlPrefix = "https://www.googletagmanager.com/gtag/js?id=";
59
const googleDataLayerName = "dataLayer";
610

711
const telemetryContract = {
812
eventsCollection: "",
913
harnessGlobalName: "",
1014
initializationsCollection: "",
1115
pageViewsCollection: "",
16+
runtimeAllowVendorLoadsProperty: "",
1217
runtimeGlobalName: "",
1318
runtimeHarnessEnabledProperty: "",
1419
runtimeWasmDebugEnabledProperty: "",
@@ -18,8 +23,10 @@ const telemetryContract = {
1823
const telemetryState = {
1924
clarityConfigured: false,
2025
clarityProjectId: "",
26+
clarityScriptLoadPromise: null,
2127
googleAnalyticsConfigured: false,
2228
googleAnalyticsMeasurementId: "",
29+
googleAnalyticsScriptLoadPromise: null,
2330
initialized: false,
2431
runtimeEnabled: false
2532
};
@@ -29,6 +36,7 @@ function applyTelemetryContract(contract) {
2936
telemetryContract.harnessGlobalName = contract?.harnessGlobalName ?? "";
3037
telemetryContract.initializationsCollection = contract?.initializationsCollection ?? "";
3138
telemetryContract.pageViewsCollection = contract?.pageViewsCollection ?? "";
39+
telemetryContract.runtimeAllowVendorLoadsProperty = contract?.runtimeAllowVendorLoadsProperty ?? "";
3240
telemetryContract.runtimeGlobalName = contract?.runtimeGlobalName ?? "";
3341
telemetryContract.runtimeHarnessEnabledProperty = contract?.runtimeHarnessEnabledProperty ?? "";
3442
telemetryContract.runtimeWasmDebugEnabledProperty = contract?.runtimeWasmDebugEnabledProperty ?? "";
@@ -48,6 +56,14 @@ function getRuntime() {
4856
return window[telemetryContract.runtimeGlobalName] ?? {};
4957
}
5058

59+
function areVendorLoadsAllowed() {
60+
return getRuntime()[telemetryContract.runtimeAllowVendorLoadsProperty] === true;
61+
}
62+
63+
function shouldBlockVendorLoads() {
64+
return getHarness() !== null && !areVendorLoadsAllowed();
65+
}
66+
5167
function ensureHarnessCollection(name) {
5268
const harness = getHarness();
5369
if (harness === null) {
@@ -91,12 +107,70 @@ function installClarityStub() {
91107
window[clarityFunctionName] = clarityStub;
92108
}
93109

94-
function recordBlockedVendorLoad(provider) {
110+
function recordVendorLoad(provider, blocked, url) {
95111
recordHarnessEntry(telemetryContract.vendorLoadsCollection, {
96-
blocked: true,
112+
blocked,
97113
provider,
98-
url: ""
114+
url
115+
});
116+
}
117+
118+
function buildGoogleAnalyticsScriptUrl(measurementId) {
119+
return `${googleAnalyticsScriptUrlPrefix}${encodeURIComponent(measurementId)}`;
120+
}
121+
122+
function buildClarityScriptUrl(projectId) {
123+
return `${clarityScriptUrlPrefix}${encodeURIComponent(projectId)}`;
124+
}
125+
126+
function loadExternalScript(provider, url, elementId, promisePropertyName) {
127+
const existingPromise = telemetryState[promisePropertyName];
128+
if (existingPromise !== null) {
129+
return existingPromise;
130+
}
131+
132+
const loadPromise = new Promise((resolve, reject) => {
133+
const handleError = () => reject(new Error(`${provider} failed to load.`));
134+
135+
const existingElement = document.getElementById(elementId);
136+
if (existingElement instanceof HTMLScriptElement) {
137+
if (existingElement.dataset.prompterOneLoaded === "true") {
138+
resolve();
139+
return;
140+
}
141+
142+
if (existingElement.dataset.prompterOneFailed === "true") {
143+
handleError();
144+
return;
145+
}
146+
147+
existingElement.addEventListener("load", resolve, { once: true });
148+
existingElement.addEventListener("error", handleError, { once: true });
149+
return;
150+
}
151+
152+
const scriptElement = document.createElement("script");
153+
scriptElement.async = true;
154+
scriptElement.id = elementId;
155+
scriptElement.src = url;
156+
scriptElement.addEventListener("load", () => {
157+
scriptElement.dataset.prompterOneLoaded = "true";
158+
resolve();
159+
}, { once: true });
160+
scriptElement.addEventListener("error", () => {
161+
scriptElement.dataset.prompterOneFailed = "true";
162+
handleError();
163+
}, { once: true });
164+
165+
recordVendorLoad(provider, false, url);
166+
(document.head ?? document.documentElement).appendChild(scriptElement);
167+
}).catch(error => {
168+
telemetryState[promisePropertyName] = null;
169+
throw error;
99170
});
171+
172+
telemetryState[promisePropertyName] = loadPromise;
173+
return loadPromise;
100174
}
101175

102176
async function ensureGoogleAnalyticsConfigured() {
@@ -105,7 +179,22 @@ async function ensureGoogleAnalyticsConfigured() {
105179
}
106180

107181
installGoogleAnalyticsStub();
108-
recordBlockedVendorLoad(googleAnalyticsProviderName);
182+
183+
if (shouldBlockVendorLoads()) {
184+
recordVendorLoad(googleAnalyticsProviderName, true, "");
185+
}
186+
else {
187+
try {
188+
await loadExternalScript(
189+
googleAnalyticsProviderName,
190+
buildGoogleAnalyticsScriptUrl(telemetryState.googleAnalyticsMeasurementId),
191+
googleAnalyticsScriptElementId,
192+
"googleAnalyticsScriptLoadPromise");
193+
}
194+
catch {
195+
return;
196+
}
197+
}
109198

110199
window[googleAnalyticsFunctionName]("js", new Date());
111200
window[googleAnalyticsFunctionName]("config", telemetryState.googleAnalyticsMeasurementId, { send_page_view: false });
@@ -118,7 +207,23 @@ async function ensureClarityConfigured() {
118207
}
119208

120209
installClarityStub();
121-
recordBlockedVendorLoad(clarityProviderName);
210+
211+
if (shouldBlockVendorLoads()) {
212+
recordVendorLoad(clarityProviderName, true, "");
213+
telemetryState.clarityConfigured = true;
214+
return;
215+
}
216+
217+
try {
218+
await loadExternalScript(
219+
clarityProviderName,
220+
buildClarityScriptUrl(telemetryState.clarityProjectId),
221+
clarityScriptElementId,
222+
"clarityScriptLoadPromise");
223+
}
224+
catch {
225+
return;
226+
}
122227

123228
telemetryState.clarityConfigured = true;
124229
}
@@ -128,9 +233,9 @@ function buildInitializationSnapshot(config) {
128233
const sentryConfigured = config?.sentryConfigured === true;
129234

130235
return {
131-
clarityConfigured: Boolean(config?.clarityProjectId),
236+
clarityConfigured: telemetryState.clarityConfigured,
132237
debugEnabled: runtime[telemetryContract.runtimeWasmDebugEnabledProperty] === true,
133-
googleAnalyticsConfigured: Boolean(config?.googleAnalyticsMeasurementId),
238+
googleAnalyticsConfigured: telemetryState.googleAnalyticsConfigured,
134239
hostEnabled: config?.hostEnabled === true,
135240
runtimeEnabled: telemetryState.runtimeEnabled,
136241
sentryConfigured,
@@ -155,14 +260,14 @@ export async function initializeRuntimeTelemetry(config) {
155260
config?.hostEnabled === true
156261
&& getRuntime()[telemetryContract.runtimeWasmDebugEnabledProperty] !== true;
157262

158-
recordHarnessEntry(telemetryContract.initializationsCollection, buildInitializationSnapshot(config));
159-
160263
if (!telemetryState.runtimeEnabled) {
264+
recordHarnessEntry(telemetryContract.initializationsCollection, buildInitializationSnapshot(config));
161265
return false;
162266
}
163267

164268
await ensureGoogleAnalyticsConfigured();
165269
await ensureClarityConfigured();
270+
recordHarnessEntry(telemetryContract.initializationsCollection, buildInitializationSnapshot(config));
166271
return true;
167272
}
168273

@@ -183,6 +288,6 @@ async function trackRuntimeTelemetryEventInternal(eventName, payload, harnessCol
183288

184289
const normalizedPayload = normalizePayload(eventName, payload);
185290
recordHarnessEntry(harnessCollectionName, normalizedPayload);
186-
window[googleAnalyticsFunctionName]("event", eventName, payload ?? {});
291+
window[googleAnalyticsFunctionName]("event", eventName, normalizedPayload);
187292
return true;
188293
}

src/PrompterOne.Web/Program.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@
2323
var runtimeTelemetryOptions = new RuntimeTelemetryOptions(
2424
builder.Configuration[RuntimeTelemetryOptions.GoogleAnalyticsMeasurementIdPath] ?? string.Empty,
2525
builder.Configuration[RuntimeTelemetryOptions.ClarityProjectIdPath] ?? string.Empty,
26-
HostEnabled: runtimeTelemetryHostEnabled,
27-
SentryConfigured: RuntimeSentryBootstrapper.IsConfigured);
26+
builder.Configuration[RuntimeTelemetryOptions.SentryDsnPath] ?? string.Empty,
27+
HostEnabled: runtimeTelemetryHostEnabled);
2828

2929
builder.Services.AddLocalization();
3030
builder.Services.AddSingleton<IFormFactor, FormFactor>();
3131
builder.Services.AddSingleton<IAppVersionProvider>(_ => AppVersionProviderFactory.CreateFromAssembly(typeof(Program).Assembly));
3232
builder.Services.AddPrompterOneShared(runtimeTelemetryOptions);
33+
RuntimeSentryBootstrapper.Configure(builder, runtimeTelemetryOptions);
3334

3435
var host = builder.Build();
35-
using var sentry = RuntimeSentryBootstrapper.Initialize(host.Services, runtimeTelemetryHostEnabled);
36+
RuntimeSentryBootstrapper.DisableForWasmDebug(host.Services);
3637
await host.Services.GetRequiredService<AppCulturePreferenceService>().InitializeAsync();
3738
await host.RunAsync();

src/PrompterOne.Web/PrompterOne.Web.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" PrivateAssets="all" />
1111
<PackageReference Include="Microsoft.Extensions.Localization" />
1212
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" />
13-
<PackageReference Include="Sentry" />
13+
<PackageReference Include="Sentry.AspNetCore.Blazor.WebAssembly" />
1414
</ItemGroup>
1515

1616
<ItemGroup>

src/PrompterOne.Web/Services/RuntimeSentryBootstrapper.cs

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,59 @@
11
using Microsoft.AspNetCore.Components;
22
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
3+
using PrompterOne.Shared.Services;
34
using PrompterOne.Shared.Settings.Services;
45

56
namespace PrompterOne.Web.Services;
67

78
internal static class RuntimeSentryBootstrapper
89
{
9-
private const string Dsn =
10-
"https://cc172c8c1921c7a979dfbb12ca80379f@o4511168317030400.ingest.de.sentry.io/4511168321749072";
1110
private const char QueryPairSeparator = '&';
1211
private const char QueryPrefix = '?';
1312
private const char QueryValueSeparator = '=';
1413
private const string WasmDebugEnabledValue = "1";
1514
private const string WasmDebugQueryKey = "wasm-debug";
1615

17-
public static bool IsConfigured => !string.IsNullOrWhiteSpace(Dsn);
18-
19-
public static IDisposable? Initialize(IServiceProvider services, bool hostEnabled)
16+
public static void Configure(WebAssemblyHostBuilder builder, RuntimeTelemetryOptions telemetryOptions)
2017
{
21-
if (!hostEnabled || !IsConfigured)
22-
{
23-
return null;
24-
}
18+
ArgumentNullException.ThrowIfNull(builder);
19+
ArgumentNullException.ThrowIfNull(telemetryOptions);
2520

26-
var hostEnvironment = services.GetRequiredService<IWebAssemblyHostEnvironment>();
27-
if (hostEnvironment.IsDevelopment())
21+
if (!ShouldEnable(builder.HostEnvironment, telemetryOptions))
2822
{
29-
return null;
23+
return;
3024
}
3125

32-
var navigationManager = services.GetRequiredService<NavigationManager>();
33-
if (IsWasmDebugEnabled(navigationManager.Uri))
34-
{
35-
return null;
36-
}
26+
var release = AppVersionProviderFactory
27+
.CreateFromAssembly(typeof(RuntimeSentryBootstrapper).Assembly)
28+
.Current
29+
.Version;
3730

38-
var appVersionProvider = services.GetRequiredService<IAppVersionProvider>();
39-
return SentrySdk.Init(options =>
31+
builder.UseSentry(options =>
4032
{
41-
options.Dsn = Dsn;
33+
options.Dsn = telemetryOptions.SentryDsn;
4234
options.Debug = false;
4335
options.AutoSessionTracking = true;
44-
options.Environment = hostEnvironment.Environment;
45-
options.Release = appVersionProvider.Current.Version;
36+
options.Environment = builder.HostEnvironment.Environment;
37+
options.Release = release;
4638
});
4739
}
4840

41+
public static void DisableForWasmDebug(IServiceProvider services)
42+
{
43+
ArgumentNullException.ThrowIfNull(services);
44+
45+
var navigationManager = services.GetRequiredService<NavigationManager>();
46+
if (IsWasmDebugEnabled(navigationManager.Uri))
47+
{
48+
SentrySdk.Close();
49+
}
50+
}
51+
52+
private static bool ShouldEnable(IWebAssemblyHostEnvironment hostEnvironment, RuntimeTelemetryOptions telemetryOptions) =>
53+
telemetryOptions.HostEnabled
54+
&& telemetryOptions.SentryConfigured
55+
&& !hostEnvironment.IsDevelopment();
56+
4957
private static bool IsWasmDebugEnabled(string uri) =>
5058
string.Equals(ResolveQueryValue(uri, WasmDebugQueryKey), WasmDebugEnabledValue, StringComparison.Ordinal);
5159

src/PrompterOne.Web/wwwroot/appsettings.Development.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"RuntimeTelemetry": {
99
"HostEnabled": false,
1010
"GoogleAnalyticsMeasurementId": "",
11-
"ClarityProjectId": ""
11+
"ClarityProjectId": "",
12+
"SentryDsn": ""
1213
}
1314
}

0 commit comments

Comments
 (0)