diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index be4341b7..246f5e70 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -64,9 +64,27 @@ 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"); + // EnrichWithHttpResponse fires after the authentication middleware has run, + // so HttpContext.User is populated and IsAuthenticated is reliable. + options.EnrichWithHttpResponse = (activity, response) => + { + var user = response.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 +510,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://*.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'", @@ -653,4 +671,51 @@ 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); + + [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)) + { + return string.Empty; + } + + string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); + if (string.IsNullOrWhiteSpace(ingestionEndpoint) + || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) + || ingestionUri.Scheme != Uri.UriSchemeHttps) + { + if (logger is not null) + { + LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); + } + 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)..].Trim('"'); + } + + return null; + } } diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3f19ac18..1c63b9d3 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,16 @@ + + @{ + 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 new file mode 100644 index 00000000..6b9846b8 --- /dev/null +++ b/EssentialCSharp.Web/wwwroot/js/appinsights-manager.js @@ -0,0 +1,230 @@ +/** + * 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() { + // 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() { + 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) { + // 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. + // 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; + existing.remove(); // remove so the next retry appends a fresh element + 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 = () => { + 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); + }); + + 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(); + + // 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(); + didInitialPageView = true; + } + + return instance; + } + + function onConsentGranted() { + const connectionString = getConnectionString(); + if (!connectionString) { + return; + } + + ensureSdkLoaded() + .then(() => { + // Re-check consent — user may have revoked while the SDK script was downloading + if (!hasAnalyticsConsent()) { + return; + } + if (!appInsights) { + appInsights = createAppInsights(); + } 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) => { + console.warn("Application Insights SDK initialization failed:", error); + }); + } + + function onConsentRevoked() { + clearAuthenticatedContext(); // guards internally + if (appInsights) { + 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() { + if (hasAnalyticsConsent()) { + onConsentGranted(); + } else { + onConsentRevoked(); + } + } + + 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 a full W3C traceparent so callers don't need to synthesise span IDs. + return `00-${traceId.toLowerCase()}-${generateSpanId()}-01`; + } + return null; + } + + window.ecsGetAppInsights = function () { + 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 getCurrentTraceparent(); + }; + + 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..0b755c80 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() { @@ -73,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); @@ -251,7 +253,7 @@ class ConsentManager { this.removeConsentBanner(); } - updateConsentMode() { + updateConsentMode({ skipNotify = false } = {}) { if (window.gtag) { try { window.gtag('consent', 'update', this.consentState); @@ -259,6 +261,9 @@ class ConsentManager { console.warn('Failed to update Google Consent Mode:', error); } } + if (!skipNotify) { + this.notifyConsentChanged(); + } } updateClarityConsent() { @@ -424,10 +429,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(); @@ -437,8 +452,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. @@ -476,4 +491,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..ddff070e 100644 --- a/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js +++ b/EssentialCSharp.Web/wwwroot/js/trydotnet-module.js @@ -17,6 +17,38 @@ 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') { + // 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; + } + + 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 +315,8 @@ export function useTryDotNet() { const configuration = { hostOrigin: hostOrigin, trydotnetOrigin: getTryDotNetOrigin(), - enableLogging: false + enableLogging: false, + correlationContext: getCorrelationContext() }; session = await withTimeout( @@ -353,12 +386,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('TryCodeRunnerRequested', eventProperties); try { await withTimeout(session.run(), RUN_TIMEOUT, ERROR_MESSAGES.runTimeout); + const durationMs = Math.round(performance.now() - startedAt); + trackTryEvent('TryCodeRunnerCompleted', { ...eventProperties, success: 'true' }, { durationMs }); } catch (error) { codeRunnerOutput.value = error.message; codeRunnerOutputError.value = true; + const durationMs = Math.round(performance.now() - startedAt); + trackTryEvent( + 'TryCodeRunnerCompleted', + { ...eventProperties, success: 'false', errorType: error?.name ?? 'Error' }, + { durationMs } + ); } finally { isRunning.value = false; } @@ -470,6 +522,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