From 4f2488dce2c89d5f142fabcd858ea5ae32afc87d Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 10:11:44 -0700 Subject: [PATCH 1/6] feat: add Application Insights browser usage telemetry - Add appinsights-manager.js: consent-aware App Insights JS SDK loader - Initializes SDK with connection string from window global - Gated on analytics consent via ecs:consent-changed event - Exposes window.ecsGetAppInsights() and window.ecsGetCorrelationContext() - No-ops gracefully when connection string is absent (local/dev) - disableCookiesUsage by default; enabled when consent granted - Update consent-manager.js: dispatch ecs:consent-changed CustomEvent and expose window.getEcsConsentState() for SDK integration - Update trydotnet-module.js: emit AI custom events for code runner lifecycle (TryCodeRunnerOpened, TryCodeRunnerRequested, TryCodeRunnerCompleted) and pass optional correlationContext into Try session config for opt-in E2E trace correlation - Update _Layout.cshtml: expose window.__ECS_AI_CONNECTION_STRING and window.__ECS_AUTH_USER_ID browser globals; include appinsights-manager.js - Update Program.cs: - Enrich OTel spans with enduser.id from NameIdentifier claim on authenticated requests - Extend CSP: add js.monitor.azure.com to script-src and dynamically parse AI ingestion endpoint for connect-src - Add GetApplicationInsightsCspSources() and GetConnectionStringValue() helpers --- EssentialCSharp.Web/Program.cs | 60 +++++- .../Views/Shared/_Layout.cshtml | 4 + .../wwwroot/js/appinsights-manager.js | 171 ++++++++++++++++++ .../wwwroot/js/consent-manager.js | 22 ++- .../wwwroot/js/trydotnet-module.js | 53 +++++- 5 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 EssentialCSharp.Web/wwwroot/js/appinsights-manager.js diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index be4341b7..e63835c1 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -64,9 +64,25 @@ private static void Main(string[] args) // Health probe paths excluded from tracing unconditionally — applies to both // manual instrumentation and Azure Monitor's auto-instrumentation. builder.Services.Configure(options => + { options.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health") - && !ctx.Request.Path.StartsWithSegments("/alive")); + && !ctx.Request.Path.StartsWithSegments("/alive"); + options.EnrichWithHttpRequest = (activity, request) => + { + var user = request.HttpContext.User; + if (user?.Identity?.IsAuthenticated != true) + { + return; + } + + string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrWhiteSpace(userId)) + { + activity.SetTag("enduser.id", userId); + } + }; + }); var otel = builder.Services.AddOpenTelemetry() .WithMetrics(metrics => @@ -492,11 +508,11 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( string csp = string.Join("; ", $"default-src 'self'", - $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", + $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com js.monitor.azure.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", $"style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", $"img-src 'self' data: https:", - $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms{tryDotNetSources}", + $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://dc.services.visualstudio.com https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])}{tryDotNetSources}", $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", $"worker-src blob:", $"frame-ancestors 'none'", @@ -653,4 +669,42 @@ private static bool IsMcpTransportRequest(HttpRequest request) => [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); + + private static string GetApplicationInsightsCspSources(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return string.Empty; + } + + string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); + if (string.IsNullOrWhiteSpace(ingestionEndpoint) || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri)) + { + return string.Empty; + } + + return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}"; + } + + private static string? GetConnectionStringValue(string connectionString, string key) + { + foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int separatorIndex = segment.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + string currentKey = segment[..separatorIndex]; + if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return segment[(separatorIndex + 1)..]; + } + + return null; + } } diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3f19ac18..7b0c6f3d 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -1,5 +1,6 @@ @using EssentialCSharp.Web.Extensions @using System.Globalization +@using System.Security.Claims @using EssentialCSharp.Web.Services @using IntelliTect.Multitool @using EssentialCSharp.Common @@ -53,6 +54,7 @@ + diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js new file mode 100644 index 00000000..80f799c2 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -0,0 +1,171 @@ +/** + * Application Insights browser telemetry manager for Essential C#. + * Reuses the existing consent-manager analytics consent signal. + */ +(function () { + const SDK_URL = "https://js.monitor.azure.com/scripts/b/ai.3.gbl.min.js"; + const CONSENT_EVENT = "ecs:consent-changed"; + + let appInsights = null; + let sdkLoadPromise = null; + let didInitialPageView = false; + + function getConnectionString() { + const value = window.APPLICATIONINSIGHTS_CONNECTION_STRING; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function hasAnalyticsConsent() { + if (window.consentManager && typeof window.consentManager.hasAnalyticsConsent === "function") { + return window.consentManager.hasAnalyticsConsent(); + } + + const state = typeof window.getEcsConsentState === "function" ? window.getEcsConsentState() : null; + return !!(state && state.analytics_storage === "granted"); + } + + function getAuthenticatedUserId() { + const userId = window.AUTHENTICATED_USER_ID; + return typeof userId === "string" && userId.trim().length > 0 ? userId.trim() : null; + } + + function setAuthenticatedContext() { + if (!appInsights) { + return; + } + + const userId = getAuthenticatedUserId(); + if (userId) { + appInsights.setAuthenticatedUserContext(userId); + } else if (typeof appInsights.clearAuthenticatedUserContext === "function") { + appInsights.clearAuthenticatedUserContext(); + } + } + + function clearAuthenticatedContext() { + if (appInsights && typeof appInsights.clearAuthenticatedUserContext === "function") { + appInsights.clearAuthenticatedUserContext(); + } + } + + function ensureSdkLoaded() { + if (window.Microsoft?.ApplicationInsights?.ApplicationInsights) { + return Promise.resolve(); + } + if (sdkLoadPromise) { + return sdkLoadPromise; + } + + sdkLoadPromise = new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${SDK_URL}"]`); + if (existing) { + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error("Failed to load App Insights SDK.")), { once: true }); + return; + } + + const script = document.createElement("script"); + script.src = SDK_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load App Insights SDK.")); + document.head.appendChild(script); + }); + + return sdkLoadPromise; + } + + function createAppInsights() { + const connectionString = getConnectionString(); + if (!connectionString) { + return null; + } + if (!window.Microsoft?.ApplicationInsights?.ApplicationInsights) { + return null; + } + + const instance = new window.Microsoft.ApplicationInsights.ApplicationInsights({ + config: { + connectionString, + disableAjaxTracking: true, // avoid duplicate/debatable dependency telemetry from browser fetch/XHR + disableTelemetry: false + } + }); + + instance.loadAppInsights(); + setAuthenticatedContext(); + + if (!didInitialPageView) { + instance.trackPageView(); + didInitialPageView = true; + } + + return instance; + } + + function onConsentGranted() { + const connectionString = getConnectionString(); + if (!connectionString) { + return; + } + + ensureSdkLoaded() + .then(() => { + if (!appInsights) { + appInsights = createAppInsights(); + window.ecsAppInsights = appInsights; + } else { + appInsights.config.disableTelemetry = false; + setAuthenticatedContext(); + } + }) + .catch((error) => { + console.warn("Application Insights SDK initialization failed:", error); + }); + } + + function onConsentRevoked() { + if (!appInsights) { + return; + } + + clearAuthenticatedContext(); + appInsights.config.disableTelemetry = true; + } + + function syncConsentState() { + if (hasAnalyticsConsent()) { + onConsentGranted(); + } else { + onConsentRevoked(); + } + } + + function getCurrentTraceId() { + const traceId = appInsights?.context?.telemetryTrace?.traceID; + if (typeof traceId === "string" && /^[a-f0-9]{32}$/i.test(traceId)) { + return traceId.toLowerCase(); + } + return null; + } + + window.ecsGetAppInsights = function () { + return appInsights; + }; + + window.ecsGetCorrelationContext = function () { + return getCurrentTraceId(); + }; + + function init() { + window.addEventListener(CONSENT_EVENT, syncConsentState); + syncConsentState(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, { once: true }); + } else { + init(); + } +})(); diff --git a/EssentialCSharp.Web/wwwroot/js/consent-manager.js b/EssentialCSharp.Web/wwwroot/js/consent-manager.js index c30cc586..5613ba8d 100644 --- a/EssentialCSharp.Web/wwwroot/js/consent-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/consent-manager.js @@ -36,6 +36,8 @@ class ConsentManager { if (this.shouldShowConsentBanner()) { this.showConsentBanner(); } + + this.notifyConsentChanged(); } initGoogleConsentMode() { @@ -259,6 +261,7 @@ class ConsentManager { console.warn('Failed to update Google Consent Mode:', error); } } + this.notifyConsentChanged(); } updateClarityConsent() { @@ -424,10 +427,20 @@ class ConsentManager { return this.consentState.analytics_storage === 'granted'; } + getConsentState() { + return { ...this.consentState }; + } + hasAdvertisingConsent() { return this.consentState.ad_storage === 'granted'; } + notifyConsentChanged() { + window.dispatchEvent(new CustomEvent('ecs:consent-changed', { + detail: { consentState: { ...this.consentState } } + })); + } + // Method to revoke consent (useful for "forget me" functionality) revokeAllConsent() { this.rejectAllConsent(); @@ -476,4 +489,11 @@ window.openConsentPreferences = function() { if (window.consentManager) { window.consentManager.openConsentPreferences(); } -}; \ No newline at end of file +}; + +window.getEcsConsentState = function() { + if (window.consentManager && typeof window.consentManager.getConsentState === 'function') { + return window.consentManager.getConsentState(); + } + return null; +}; diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js index 47345add..b4fa2179 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -17,6 +17,33 @@ const ERROR_MESSAGES = { fetchFailed: 'Could not load the listing source code. Please try again.', }; +function getAppInsights() { + if (typeof window.ecsGetAppInsights === 'function') { + return window.ecsGetAppInsights(); + } + return null; +} + +function getCorrelationContext() { + if (typeof window.ecsGetCorrelationContext === 'function') { + return window.ecsGetCorrelationContext(); + } + return null; +} + +function trackTryEvent(name, properties = {}, measurements = {}) { + const appInsights = getAppInsights(); + if (!appInsights || typeof appInsights.trackEvent !== 'function') { + return; + } + + try { + appInsights.trackEvent({ name }, properties, measurements); + } catch (error) { + console.warn('Failed to track Try telemetry event:', error); + } +} + /** * Races a promise against a timeout. Rejects with the given message if the * timeout fires first. @@ -283,7 +310,8 @@ export function useTryDotNet() { const configuration = { hostOrigin: hostOrigin, trydotnetOrigin: getTryDotNetOrigin(), - enableLogging: false + enableLogging: false, + correlationContext: getCorrelationContext() }; session = await withTimeout( @@ -353,12 +381,31 @@ export function useTryDotNet() { codeRunnerOutput.value = 'Running...'; codeRunnerOutputError.value = false; isRunning.value = true; + const startedAt = performance.now(); + const listingInfo = currentListingInfo.value; + const eventProperties = listingInfo + ? { + chapter: String(listingInfo.chapter), + listing: String(listingInfo.listing), + listingId: `${listingInfo.chapter}.${listingInfo.listing}` + } + : {}; + + trackTryEvent('TryCodeRunRequested', eventProperties); try { await withTimeout(session.run(), RUN_TIMEOUT, ERROR_MESSAGES.runTimeout); + const durationMs = Math.round(performance.now() - startedAt); + trackTryEvent('TryCodeRunCompleted', { ...eventProperties, success: 'true' }, { durationMs }); } catch (error) { codeRunnerOutput.value = error.message; codeRunnerOutputError.value = true; + const durationMs = Math.round(performance.now() - startedAt); + trackTryEvent( + 'TryCodeRunCompleted', + { ...eventProperties, success: 'false', errorType: error?.name ?? 'Error' }, + { durationMs } + ); } finally { isRunning.value = false; } @@ -470,6 +517,10 @@ export function useTryDotNet() { codeRunnerOutputError.value = false; const listingKey = `${chapter}.${listing}`; + trackTryEvent( + 'TryCodeRunnerOpened', + { chapter: String(chapter), listing: String(listing), listingId: listingKey } + ); try { // Load the library if not already loaded From f342f7a4f31f263a94e0be789b5fb008da1efba3 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 10:32:10 -0700 Subject: [PATCH 2/6] fix: address multi-model review findings in App Insights telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove disableCookiesUsage: true — was silently breaking cross-session anonymous user analytics (every tab = new user ID). Consented users now correctly persist ai_user across sessions. - onConsentRevoked() explicitly deletes ai_user/ai_session cookies on every page load, unconditionally (not guarded on appInsights init). Covers the critical case: returning visitor with stale cookies from a prior consented session who then denied consent and closed the browser. - Fix setAuthenticatedContext() no-op: call instance.setAuthenticatedUserContext() directly in createAppInsights() instead of via module-level guard which was always null at that point. - Reset sdkLoadPromise=null on onerror to allow retry on transient CDN failure. - Add 15s timeout in ensureSdkLoaded() when attaching to existing script tag to prevent hang if script already errored before listeners were attached. - Add ai_user/ai_session to consent-manager clearTrackingCookies() as defense-in-depth for the 'forget me' revocation path. Reviewed and approved by Opus 4.6 and GPT-5.5. --- .../wwwroot/js/appinsights-manager.js | 47 +++++++++++++++---- .../wwwroot/js/consent-manager.js | 4 +- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js index 80f799c2..ca6a0503 100644 --- a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -59,8 +59,18 @@ sdkLoadPromise = new Promise((resolve, reject) => { const existing = document.querySelector(`script[src="${SDK_URL}"]`); if (existing) { - existing.addEventListener("load", () => resolve(), { once: true }); - existing.addEventListener("error", () => reject(new Error("Failed to load App Insights SDK.")), { once: true }); + // Guard: script may have already loaded successfully + if (window.Microsoft?.ApplicationInsights?.ApplicationInsights) { + resolve(); + return; + } + // Guard: script may have already errored — add timeout so promise doesn't hang forever + const timeoutId = setTimeout(() => { + sdkLoadPromise = null; + reject(new Error("App Insights SDK load timed out.")); + }, 15000); + existing.addEventListener("load", () => { clearTimeout(timeoutId); resolve(); }, { once: true }); + existing.addEventListener("error", () => { clearTimeout(timeoutId); sdkLoadPromise = null; reject(new Error("Failed to load App Insights SDK.")); }, { once: true }); return; } @@ -69,7 +79,10 @@ script.async = true; script.defer = true; script.onload = () => resolve(); - script.onerror = () => reject(new Error("Failed to load App Insights SDK.")); + script.onerror = () => { + sdkLoadPromise = null; // allow retry on transient failure + reject(new Error("Failed to load App Insights SDK.")); + }; document.head.appendChild(script); }); @@ -94,7 +107,13 @@ }); instance.loadAppInsights(); - setAuthenticatedContext(); + + // Set authenticated context on `instance` directly — the module-level `appInsights` variable + // is not yet assigned at this point, so setAuthenticatedContext() would be a no-op. + const userId = getAuthenticatedUserId(); + if (userId) { + instance.setAuthenticatedUserContext(userId); + } if (!didInitialPageView) { instance.trackPageView(); @@ -126,12 +145,24 @@ } function onConsentRevoked() { - if (!appInsights) { - return; + clearAuthenticatedContext(); // guards internally + if (appInsights) { + appInsights.config.disableTelemetry = true; } - clearAuthenticatedContext(); - appInsights.config.disableTelemetry = true; + // Run unconditionally — appInsights may never have been initialized this session + // (user has always denied), but ai_user/ai_session cookies from a prior consented + // session can still be present in the browser. + // consent-manager.clearTrackingCookies() only runs on the "forget me" path; + // normal reject/revoke flows fire the consent event without calling it. + const expired = "expires=Thu, 01 Jan 1970 00:00:00 GMT"; + const secure = window.location.protocol === "https:" ? ";Secure" : ""; + const hostname = window.location.hostname; + ["ai_user", "ai_session"].forEach(function (name) { + document.cookie = `${name}=;${expired};path=/${secure}`; + document.cookie = `${name}=;${expired};path=/;domain=${hostname}${secure}`; + document.cookie = `${name}=;${expired};path=/;domain=.${hostname}${secure}`; + }); } function syncConsentState() { diff --git a/EssentialCSharp.Web/wwwroot/js/consent-manager.js b/EssentialCSharp.Web/wwwroot/js/consent-manager.js index 5613ba8d..e8e9d1bc 100644 --- a/EssentialCSharp.Web/wwwroot/js/consent-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/consent-manager.js @@ -450,8 +450,8 @@ class ConsentManager { } clearTrackingCookies() { - // Clear common tracking cookies (Google Analytics and Microsoft Clarity) - const trackingCookies = ['_ga', '_gid', '_gat', '_clck', '_clsk', 'CLID', 'ANONCHK', 'MR', 'MUID', 'SM']; + // Clear common tracking cookies (Google Analytics, Microsoft Clarity, and App Insights) + const trackingCookies = ['_ga', '_gid', '_gat', '_clck', '_clsk', 'CLID', 'ANONCHK', 'MR', 'MUID', 'SM', 'ai_user', 'ai_session']; const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT'; const hostname = window.location.hostname; // Build candidate domains: exact host plus progressively shorter parent domains. From 81650821a720dfa16829f03eb3ca114b6f7b5d51 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 11:34:17 -0700 Subject: [PATCH 3/6] fix: address PR review comments on App Insights telemetry - appinsights-manager.js: ecsGetCorrelationContext() now returns a full W3C traceparent (00-{traceId}-{spanId}-01) instead of a bare 32-hex traceId, removing ambiguity for callers - appinsights-manager.js: script.remove() on onerror and on 15s timeout so the dead element is cleaned up and a subsequent retry appends a fresh + @{ + string? authUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + } + @if (!string.IsNullOrEmpty(authUserId)) + { + // Scoped to a tag rather than a window global to avoid exposing the stable + // user GUID to third-party scripts that enumerate window properties. + + } diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js index ca6a0503..7ea54b32 100644 --- a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -25,8 +25,12 @@ } function getAuthenticatedUserId() { - const userId = window.AUTHENTICATED_USER_ID; - return typeof userId === "string" && userId.trim().length > 0 ? userId.trim() : null; + // Read from a tag rather than a window global to avoid exposing the stable + // user GUID to third-party scripts that enumerate window properties. + const meta = document.querySelector('meta[name="ecs-auth-user-id"]'); + if (!meta) { return null; } + const value = meta.getAttribute("content") || ""; + return value.trim().length > 0 ? value.trim() : null; } function setAuthenticatedContext() { @@ -64,13 +68,20 @@ resolve(); return; } - // Guard: script may have already errored — add timeout so promise doesn't hang forever + // Guard: script may have already errored — add timeout so promise doesn't hang forever. + // On timeout, remove the dead element so the next retry can append a fresh one. const timeoutId = setTimeout(() => { sdkLoadPromise = null; + existing.remove(); reject(new Error("App Insights SDK load timed out.")); }, 15000); existing.addEventListener("load", () => { clearTimeout(timeoutId); resolve(); }, { once: true }); - existing.addEventListener("error", () => { clearTimeout(timeoutId); sdkLoadPromise = null; reject(new Error("Failed to load App Insights SDK.")); }, { once: true }); + existing.addEventListener("error", () => { + clearTimeout(timeoutId); + sdkLoadPromise = null; + existing.remove(); // remove so the next retry appends a fresh element + reject(new Error("Failed to load App Insights SDK.")); + }, { once: true }); return; } @@ -81,6 +92,7 @@ script.onload = () => resolve(); script.onerror = () => { sdkLoadPromise = null; // allow retry on transient failure + script.remove(); // remove dead element so the next retry appends a fresh one reject(new Error("Failed to load App Insights SDK.")); }; document.head.appendChild(script); @@ -173,10 +185,17 @@ } } - function getCurrentTraceId() { + function generateSpanId() { + const arr = new Uint8Array(8); + crypto.getRandomValues(arr); + return Array.from(arr, function (b) { return b.toString(16).padStart(2, "0"); }).join(""); + } + + function getCurrentTraceparent() { const traceId = appInsights?.context?.telemetryTrace?.traceID; if (typeof traceId === "string" && /^[a-f0-9]{32}$/i.test(traceId)) { - return traceId.toLowerCase(); + // Return a full W3C traceparent so callers don't need to synthesise span IDs. + return `00-${traceId.toLowerCase()}-${generateSpanId()}-01`; } return null; } @@ -185,8 +204,10 @@ return appInsights; }; + // Returns a W3C traceparent string (00-{traceId}-{spanId}-01) suitable for passing + // as configuration.correlationContext to the TryDotNet SDK. window.ecsGetCorrelationContext = function () { - return getCurrentTraceId(); + return getCurrentTraceparent(); }; function init() { From 595ee38c430e44d9ea5966652464e00d1fa91052 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 11:47:15 -0700 Subject: [PATCH 4/6] fix: address PR review comments - event naming, consent race, CSP logging - Rename TryCodeRunRequested/TryCodeRunCompleted to TryCodeRunnerRequested/TryCodeRunnerCompleted for consistency with TryCodeRunnerOpened - Add hasAnalyticsConsent() re-check in onConsentGranted().then() to guard against consent race (user revokes while SDK downloads) - Add .Trim(quotation marks) to GetConnectionStringValue return value for quoted endpoint values - Add ILogger param and LogInvalidApplicationInsightsIngestionEndpoint warning when IngestionEndpoint is non-HTTPS or unparseable --- EssentialCSharp.Web/Program.cs | 13 ++++++++++--- .../wwwroot/js/appinsights-manager.js | 4 ++++ EssentialCSharp.Web/wwwroot/js/trydotnet-module.js | 6 +++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index f9f5a641..d6958f7d 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -512,7 +512,7 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( $"style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", $"img-src 'self' data: https:", - $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])}{tryDotNetSources}", + $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"], app.Logger)}{tryDotNetSources}", $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", $"worker-src blob:", $"frame-ancestors 'none'", @@ -670,7 +670,10 @@ private static bool IsMcpTransportRequest(HttpRequest request) => [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); - private static string GetApplicationInsightsCspSources(string? connectionString) + [LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")] + private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint); + + private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null) { if (string.IsNullOrWhiteSpace(connectionString)) { @@ -682,6 +685,10 @@ private static string GetApplicationInsightsCspSources(string? connectionString) || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) || ingestionUri.Scheme != Uri.UriSchemeHttps) { + if (logger is not null) + { + LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); + } return string.Empty; } @@ -704,7 +711,7 @@ private static string GetApplicationInsightsCspSources(string? connectionString) continue; } - return segment[(separatorIndex + 1)..]; + return segment[(separatorIndex + 1)..].Trim('"'); } return null; diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js index 7ea54b32..65775a9d 100644 --- a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -143,6 +143,10 @@ ensureSdkLoaded() .then(() => { + // Re-check consent — user may have revoked while the SDK script was downloading + if (!hasAnalyticsConsent()) { + return; + } if (!appInsights) { appInsights = createAppInsights(); window.ecsAppInsights = appInsights; diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js index b4fa2179..95fee842 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -391,18 +391,18 @@ export function useTryDotNet() { } : {}; - trackTryEvent('TryCodeRunRequested', eventProperties); + trackTryEvent('TryCodeRunnerRequested', eventProperties); try { await withTimeout(session.run(), RUN_TIMEOUT, ERROR_MESSAGES.runTimeout); const durationMs = Math.round(performance.now() - startedAt); - trackTryEvent('TryCodeRunCompleted', { ...eventProperties, success: 'true' }, { durationMs }); + trackTryEvent('TryCodeRunnerCompleted', { ...eventProperties, success: 'true' }, { durationMs }); } catch (error) { codeRunnerOutput.value = error.message; codeRunnerOutputError.value = true; const durationMs = Math.round(performance.now() - startedAt); trackTryEvent( - 'TryCodeRunCompleted', + 'TryCodeRunnerCompleted', { ...eventProperties, success: 'false', errorType: error?.name ?? 'Error' }, { durationMs } ); From 16f6280f19009913905087ca3f2fdcefe9f73506 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:02:03 -0700 Subject: [PATCH 5/6] fix: address PR review comments - enduser.id timing, window global, consent double-dispatch - Switch EnrichWithHttpRequest -> EnrichWithHttpResponse so enduser.id is set after authentication middleware has run and HttpContext.User is populated - Remove window.ecsAppInsights assignment; window.ecsGetAppInsights() already provides controlled first-party access without exposing authenticatedId to third-party scripts via window enumeration - Fix double ecs:consent-changed dispatch on returning-visitor page load: pass skipNotify:true when updateConsentMode() is called from loadConsentPreferences() so init()'s notifyConsentChanged() fires exactly once regardless --- EssentialCSharp.Web/Program.cs | 6 ++++-- EssentialCSharp.Web/wwwroot/js/appinsights-manager.js | 1 - EssentialCSharp.Web/wwwroot/js/consent-manager.js | 8 +++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index d6958f7d..246f5e70 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -68,9 +68,11 @@ private static void Main(string[] args) options.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health") && !ctx.Request.Path.StartsWithSegments("/alive"); - options.EnrichWithHttpRequest = (activity, request) => + // EnrichWithHttpResponse fires after the authentication middleware has run, + // so HttpContext.User is populated and IsAuthenticated is reliable. + options.EnrichWithHttpResponse = (activity, response) => { - var user = request.HttpContext.User; + var user = response.HttpContext.User; if (user?.Identity?.IsAuthenticated != true) { return; diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js index 65775a9d..0658a648 100644 --- a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -149,7 +149,6 @@ } if (!appInsights) { appInsights = createAppInsights(); - window.ecsAppInsights = appInsights; } else { appInsights.config.disableTelemetry = false; setAuthenticatedContext(); diff --git a/EssentialCSharp.Web/wwwroot/js/consent-manager.js b/EssentialCSharp.Web/wwwroot/js/consent-manager.js index e8e9d1bc..0b755c80 100644 --- a/EssentialCSharp.Web/wwwroot/js/consent-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/consent-manager.js @@ -75,7 +75,7 @@ class ConsentManager { }); this.consentState = { ...this.consentState, ...validatedPreferences }; - this.updateConsentMode(); + this.updateConsentMode({ skipNotify: true }); } catch (e) { // Malformed cookie — delete it so the banner is shown again console.warn('Failed to parse consent preferences', e); @@ -253,7 +253,7 @@ class ConsentManager { this.removeConsentBanner(); } - updateConsentMode() { + updateConsentMode({ skipNotify = false } = {}) { if (window.gtag) { try { window.gtag('consent', 'update', this.consentState); @@ -261,7 +261,9 @@ class ConsentManager { console.warn('Failed to update Google Consent Mode:', error); } } - this.notifyConsentChanged(); + if (!skipNotify) { + this.notifyConsentChanged(); + } } updateClarityConsent() { From be9faa8806073d47b0127e09e609660c7b65aa4f Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sat, 16 May 2026 15:39:54 -0700 Subject: [PATCH 6/6] docs: clarify re-grant page-view suppression and event race tradeoff - appinsights-manager.js: add comment explaining no trackPageView() on consent re-grant within the same page lifetime (initial page view was already recorded; re-tracking would duplicate the same URL visit) - trydotnet-module.js: add comment acknowledging the SDK-loading race window where TryCodeRunner events may be dropped/unpaired (accepted v1 tradeoff for a low-frequency edge case) --- EssentialCSharp.Web/wwwroot/js/appinsights-manager.js | 4 ++++ EssentialCSharp.Web/wwwroot/js/trydotnet-module.js | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js index 0658a648..6b9846b8 100644 --- a/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -152,6 +152,10 @@ } else { appInsights.config.disableTelemetry = false; setAuthenticatedContext(); + // Intentionally no trackPageView() here: the instance was created (and the + // initial page view recorded) during a previous consent-granted cycle in this + // same page lifetime. Re-tracking would produce a duplicate page view for + // the same URL visit. } }) .catch((error) => { diff --git a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js index 95fee842..ddff070e 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -34,6 +34,11 @@ function getCorrelationContext() { function trackTryEvent(name, properties = {}, measurements = {}) { const appInsights = getAppInsights(); if (!appInsights || typeof appInsights.trackEvent !== 'function') { + // No-op when the SDK is unavailable (consent denied, or SDK still loading). + // Known limitation: if consent is granted but the CDN script hasn't finished + // downloading yet, TryCodeRunnerRequested/TryCodeRunnerCompleted for a run + // started during that window may be dropped or mismatched. This is accepted + // as a low-frequency edge case for v1. return; }