Skip to content

Commit ca6b88a

Browse files
feat: Add Application Insights browser usage telemetry (#1118)
## Summary Implements Azure Monitor Application Insights dual-instrumentation per Microsoft's [usage analysis docs](https://learn.microsoft.com/en-us/azure/azure-monitor/app/usage). ### What's added **Browser (client-side)** - New \�ppinsights-manager.js\: consent-aware App Insights JS SDK loader - Loads SDK from \js.monitor.azure.com\ only after \�nalytics_storage: granted\ - \disableTelemetry = true\ + explicit \�i_user\/\�i_session\ cookie deletion on consent revocation (runs unconditionally — covers returning visitors with stale cookies) - Exposes \window.ecsGetAppInsights()\ and \window.ecsGetCorrelationContext()\ for cross-module use - \consent-manager.js\: dispatches \�cs:consent-changed\ CustomEvent; adds \�i_user\/\�i_session\ to \clearTrackingCookies()\ for the 'forget me' path **Custom events (code runner lifecycle)** - \ rydotnet-module.js\: fires \TryCodeRunnerOpened\, \TryCodeRunnerRequested\, \TryCodeRunnerCompleted\ custom events; passes W3C \correlationContext\ to the Try SDK for optional E2E trace correlation **Server-side** - \Program.cs\: \EnrichWithHttpRequest\ callback sets \�nduser.id\ OTel tag from \NameIdentifier\ claim (stable GUID, non-PII) for authenticated requests - CSP updated: \js.monitor.azure.com\ in \script-src\; \https://*.in.applicationinsights.azure.com\ + dynamic connection string endpoint in \connect-src\ **Layout** - \_Layout.cshtml\: exposes \window.APPLICATIONINSIGHTS_CONNECTION_STRING\ and \window.AUTHENTICATED_USER_ID\ via \@Json.Serialize()\; includes \�ppinsights-manager.js\ ### Privacy / GDPR notes - App Insights connection string in browser is intentional and safe (ingestion-only key) - \�i_user\/\�i_session\ cookies persist only while consent is granted; cleared on every page load for denied-consent users - \�nduser.id\ uses the stable Identity GUID, not email — privacy policy update needed (separate PR/ticket)
1 parent d1c42b6 commit ca6b88a

5 files changed

Lines changed: 394 additions & 9 deletions

File tree

EssentialCSharp.Web/Program.cs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,27 @@ private static void Main(string[] args)
6464
// Health probe paths excluded from tracing unconditionally — applies to both
6565
// manual instrumentation and Azure Monitor's auto-instrumentation.
6666
builder.Services.Configure<AspNetCoreTraceInstrumentationOptions>(options =>
67+
{
6768
options.Filter = ctx =>
6869
!ctx.Request.Path.StartsWithSegments("/health")
69-
&& !ctx.Request.Path.StartsWithSegments("/alive"));
70+
&& !ctx.Request.Path.StartsWithSegments("/alive");
71+
// EnrichWithHttpResponse fires after the authentication middleware has run,
72+
// so HttpContext.User is populated and IsAuthenticated is reliable.
73+
options.EnrichWithHttpResponse = (activity, response) =>
74+
{
75+
var user = response.HttpContext.User;
76+
if (user?.Identity?.IsAuthenticated != true)
77+
{
78+
return;
79+
}
80+
81+
string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
82+
if (!string.IsNullOrWhiteSpace(userId))
83+
{
84+
activity.SetTag("enduser.id", userId);
85+
}
86+
};
87+
});
7088

7189
var otel = builder.Services.AddOpenTelemetry()
7290
.WithMetrics(metrics =>
@@ -492,11 +510,11 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
492510

493511
string csp = string.Join("; ",
494512
$"default-src 'self'",
495-
$"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}",
513+
$"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}",
496514
$"style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com",
497515
$"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com",
498516
$"img-src 'self' data: https:",
499-
$"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}",
517+
$"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}",
500518
$"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}",
501519
$"worker-src blob:",
502520
$"frame-ancestors 'none'",
@@ -653,4 +671,51 @@ private static bool IsMcpTransportRequest(HttpRequest request) =>
653671

654672
[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.")]
655673
private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger<Program> logger, string platform);
674+
675+
[LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")]
676+
private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint);
677+
678+
private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null)
679+
{
680+
if (string.IsNullOrWhiteSpace(connectionString))
681+
{
682+
return string.Empty;
683+
}
684+
685+
string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint");
686+
if (string.IsNullOrWhiteSpace(ingestionEndpoint)
687+
|| !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri)
688+
|| ingestionUri.Scheme != Uri.UriSchemeHttps)
689+
{
690+
if (logger is not null)
691+
{
692+
LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint);
693+
}
694+
return string.Empty;
695+
}
696+
697+
return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}";
698+
}
699+
700+
private static string? GetConnectionStringValue(string connectionString, string key)
701+
{
702+
foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
703+
{
704+
int separatorIndex = segment.IndexOf('=');
705+
if (separatorIndex <= 0)
706+
{
707+
continue;
708+
}
709+
710+
string currentKey = segment[..separatorIndex];
711+
if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase))
712+
{
713+
continue;
714+
}
715+
716+
return segment[(separatorIndex + 1)..].Trim('"');
717+
}
718+
719+
return null;
720+
}
656721
}

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@using EssentialCSharp.Web.Extensions
22
@using System.Globalization
3+
@using System.Security.Claims
34
@using EssentialCSharp.Web.Services
45
@using IntelliTect.Multitool
56
@using EssentialCSharp.Common
@@ -53,6 +54,16 @@
5354
<meta name="theme-color" content="#ffffff">
5455
<!-- Cookie Consent Manager - Load before analytics -->
5556
<script src="~/js/consent-manager.js" asp-append-version="true"></script>
57+
<script src="~/js/appinsights-manager.js" asp-append-version="true"></script>
58+
@{
59+
string? authUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
60+
}
61+
@if (!string.IsNullOrEmpty(authUserId))
62+
{
63+
// Scoped to a <meta> tag rather than a window global to avoid exposing the stable
64+
// user GUID to third-party scripts that enumerate window properties.
65+
<meta name="ecs-auth-user-id" content="@authUserId" />
66+
}
5667

5768
<!-- Microsoft Clarity - Will be activated based on consent -->
5869
<script type="text/javascript">
@@ -190,6 +201,7 @@
190201
window.REFERRAL_ID = @Json.Serialize(ViewBag.ReferralId);
191202
window.IS_AUTHENTICATED = @Json.Serialize(SignInManager.IsSignedIn(User));
192203
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
204+
window.APPLICATIONINSIGHTS_CONNECTION_STRING = @Json.Serialize(Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]);
193205
window.BUILD_LABEL = @Json.Serialize(buildLabel);
194206
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
195207
</script>
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* Application Insights browser telemetry manager for Essential C#.
3+
* Reuses the existing consent-manager analytics consent signal.
4+
*/
5+
(function () {
6+
const SDK_URL = "https://js.monitor.azure.com/scripts/b/ai.3.gbl.min.js";
7+
const CONSENT_EVENT = "ecs:consent-changed";
8+
9+
let appInsights = null;
10+
let sdkLoadPromise = null;
11+
let didInitialPageView = false;
12+
13+
function getConnectionString() {
14+
const value = window.APPLICATIONINSIGHTS_CONNECTION_STRING;
15+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
16+
}
17+
18+
function hasAnalyticsConsent() {
19+
if (window.consentManager && typeof window.consentManager.hasAnalyticsConsent === "function") {
20+
return window.consentManager.hasAnalyticsConsent();
21+
}
22+
23+
const state = typeof window.getEcsConsentState === "function" ? window.getEcsConsentState() : null;
24+
return !!(state && state.analytics_storage === "granted");
25+
}
26+
27+
function getAuthenticatedUserId() {
28+
// Read from a <meta> tag rather than a window global to avoid exposing the stable
29+
// user GUID to third-party scripts that enumerate window properties.
30+
const meta = document.querySelector('meta[name="ecs-auth-user-id"]');
31+
if (!meta) { return null; }
32+
const value = meta.getAttribute("content") || "";
33+
return value.trim().length > 0 ? value.trim() : null;
34+
}
35+
36+
function setAuthenticatedContext() {
37+
if (!appInsights) {
38+
return;
39+
}
40+
41+
const userId = getAuthenticatedUserId();
42+
if (userId) {
43+
appInsights.setAuthenticatedUserContext(userId);
44+
} else if (typeof appInsights.clearAuthenticatedUserContext === "function") {
45+
appInsights.clearAuthenticatedUserContext();
46+
}
47+
}
48+
49+
function clearAuthenticatedContext() {
50+
if (appInsights && typeof appInsights.clearAuthenticatedUserContext === "function") {
51+
appInsights.clearAuthenticatedUserContext();
52+
}
53+
}
54+
55+
function ensureSdkLoaded() {
56+
if (window.Microsoft?.ApplicationInsights?.ApplicationInsights) {
57+
return Promise.resolve();
58+
}
59+
if (sdkLoadPromise) {
60+
return sdkLoadPromise;
61+
}
62+
63+
sdkLoadPromise = new Promise((resolve, reject) => {
64+
const existing = document.querySelector(`script[src="${SDK_URL}"]`);
65+
if (existing) {
66+
// Guard: script may have already loaded successfully
67+
if (window.Microsoft?.ApplicationInsights?.ApplicationInsights) {
68+
resolve();
69+
return;
70+
}
71+
// Guard: script may have already errored — add timeout so promise doesn't hang forever.
72+
// On timeout, remove the dead element so the next retry can append a fresh one.
73+
const timeoutId = setTimeout(() => {
74+
sdkLoadPromise = null;
75+
existing.remove();
76+
reject(new Error("App Insights SDK load timed out."));
77+
}, 15000);
78+
existing.addEventListener("load", () => { clearTimeout(timeoutId); resolve(); }, { once: true });
79+
existing.addEventListener("error", () => {
80+
clearTimeout(timeoutId);
81+
sdkLoadPromise = null;
82+
existing.remove(); // remove so the next retry appends a fresh element
83+
reject(new Error("Failed to load App Insights SDK."));
84+
}, { once: true });
85+
return;
86+
}
87+
88+
const script = document.createElement("script");
89+
script.src = SDK_URL;
90+
script.async = true;
91+
script.defer = true;
92+
script.onload = () => resolve();
93+
script.onerror = () => {
94+
sdkLoadPromise = null; // allow retry on transient failure
95+
script.remove(); // remove dead element so the next retry appends a fresh one
96+
reject(new Error("Failed to load App Insights SDK."));
97+
};
98+
document.head.appendChild(script);
99+
});
100+
101+
return sdkLoadPromise;
102+
}
103+
104+
function createAppInsights() {
105+
const connectionString = getConnectionString();
106+
if (!connectionString) {
107+
return null;
108+
}
109+
if (!window.Microsoft?.ApplicationInsights?.ApplicationInsights) {
110+
return null;
111+
}
112+
113+
const instance = new window.Microsoft.ApplicationInsights.ApplicationInsights({
114+
config: {
115+
connectionString,
116+
disableAjaxTracking: true, // avoid duplicate/debatable dependency telemetry from browser fetch/XHR
117+
disableTelemetry: false
118+
}
119+
});
120+
121+
instance.loadAppInsights();
122+
123+
// Set authenticated context on `instance` directly — the module-level `appInsights` variable
124+
// is not yet assigned at this point, so setAuthenticatedContext() would be a no-op.
125+
const userId = getAuthenticatedUserId();
126+
if (userId) {
127+
instance.setAuthenticatedUserContext(userId);
128+
}
129+
130+
if (!didInitialPageView) {
131+
instance.trackPageView();
132+
didInitialPageView = true;
133+
}
134+
135+
return instance;
136+
}
137+
138+
function onConsentGranted() {
139+
const connectionString = getConnectionString();
140+
if (!connectionString) {
141+
return;
142+
}
143+
144+
ensureSdkLoaded()
145+
.then(() => {
146+
// Re-check consent — user may have revoked while the SDK script was downloading
147+
if (!hasAnalyticsConsent()) {
148+
return;
149+
}
150+
if (!appInsights) {
151+
appInsights = createAppInsights();
152+
} else {
153+
appInsights.config.disableTelemetry = false;
154+
setAuthenticatedContext();
155+
// Intentionally no trackPageView() here: the instance was created (and the
156+
// initial page view recorded) during a previous consent-granted cycle in this
157+
// same page lifetime. Re-tracking would produce a duplicate page view for
158+
// the same URL visit.
159+
}
160+
})
161+
.catch((error) => {
162+
console.warn("Application Insights SDK initialization failed:", error);
163+
});
164+
}
165+
166+
function onConsentRevoked() {
167+
clearAuthenticatedContext(); // guards internally
168+
if (appInsights) {
169+
appInsights.config.disableTelemetry = true;
170+
}
171+
172+
// Run unconditionally — appInsights may never have been initialized this session
173+
// (user has always denied), but ai_user/ai_session cookies from a prior consented
174+
// session can still be present in the browser.
175+
// consent-manager.clearTrackingCookies() only runs on the "forget me" path;
176+
// normal reject/revoke flows fire the consent event without calling it.
177+
const expired = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
178+
const secure = window.location.protocol === "https:" ? ";Secure" : "";
179+
const hostname = window.location.hostname;
180+
["ai_user", "ai_session"].forEach(function (name) {
181+
document.cookie = `${name}=;${expired};path=/${secure}`;
182+
document.cookie = `${name}=;${expired};path=/;domain=${hostname}${secure}`;
183+
document.cookie = `${name}=;${expired};path=/;domain=.${hostname}${secure}`;
184+
});
185+
}
186+
187+
function syncConsentState() {
188+
if (hasAnalyticsConsent()) {
189+
onConsentGranted();
190+
} else {
191+
onConsentRevoked();
192+
}
193+
}
194+
195+
function generateSpanId() {
196+
const arr = new Uint8Array(8);
197+
crypto.getRandomValues(arr);
198+
return Array.from(arr, function (b) { return b.toString(16).padStart(2, "0"); }).join("");
199+
}
200+
201+
function getCurrentTraceparent() {
202+
const traceId = appInsights?.context?.telemetryTrace?.traceID;
203+
if (typeof traceId === "string" && /^[a-f0-9]{32}$/i.test(traceId)) {
204+
// Return a full W3C traceparent so callers don't need to synthesise span IDs.
205+
return `00-${traceId.toLowerCase()}-${generateSpanId()}-01`;
206+
}
207+
return null;
208+
}
209+
210+
window.ecsGetAppInsights = function () {
211+
return appInsights;
212+
};
213+
214+
// Returns a W3C traceparent string (00-{traceId}-{spanId}-01) suitable for passing
215+
// as configuration.correlationContext to the TryDotNet SDK.
216+
window.ecsGetCorrelationContext = function () {
217+
return getCurrentTraceparent();
218+
};
219+
220+
function init() {
221+
window.addEventListener(CONSENT_EVENT, syncConsentState);
222+
syncConsentState();
223+
}
224+
225+
if (document.readyState === "loading") {
226+
document.addEventListener("DOMContentLoaded", init, { once: true });
227+
} else {
228+
init();
229+
}
230+
})();

0 commit comments

Comments
 (0)