Skip to content

Commit daebe95

Browse files
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
1 parent c90b5b7 commit daebe95

5 files changed

Lines changed: 305 additions & 5 deletions

File tree

EssentialCSharp.Web/Program.cs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,25 @@ private static void Main(string[] args)
6161
// Health probe paths excluded from tracing unconditionally — applies to both
6262
// manual instrumentation and Azure Monitor's auto-instrumentation.
6363
builder.Services.Configure<AspNetCoreTraceInstrumentationOptions>(options =>
64+
{
6465
options.Filter = ctx =>
6566
!ctx.Request.Path.StartsWithSegments("/health")
66-
&& !ctx.Request.Path.StartsWithSegments("/alive"));
67+
&& !ctx.Request.Path.StartsWithSegments("/alive");
68+
options.EnrichWithHttpRequest = (activity, request) =>
69+
{
70+
var user = request.HttpContext.User;
71+
if (user?.Identity?.IsAuthenticated != true)
72+
{
73+
return;
74+
}
75+
76+
string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
77+
if (!string.IsNullOrWhiteSpace(userId))
78+
{
79+
activity.SetTag("enduser.id", userId);
80+
}
81+
};
82+
});
6783

6884
var otel = builder.Services.AddOpenTelemetry()
6985
.WithMetrics(metrics =>
@@ -478,11 +494,11 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
478494

479495
string csp = string.Join("; ",
480496
$"default-src 'self'",
481-
$"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}",
497+
$"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}",
482498
$"style-src 'self' 'unsafe-inline' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com",
483499
$"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com",
484500
$"img-src 'self' data: https:",
485-
$"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}",
501+
$"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}",
486502
$"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}",
487503
$"worker-src blob:",
488504
$"frame-ancestors 'none'",
@@ -623,4 +639,42 @@ private static bool IsMcpTransportRequest(HttpRequest request) =>
623639

624640
[LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")]
625641
private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin);
642+
643+
private static string GetApplicationInsightsCspSources(string? connectionString)
644+
{
645+
if (string.IsNullOrWhiteSpace(connectionString))
646+
{
647+
return string.Empty;
648+
}
649+
650+
string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint");
651+
if (string.IsNullOrWhiteSpace(ingestionEndpoint) || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri))
652+
{
653+
return string.Empty;
654+
}
655+
656+
return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}";
657+
}
658+
659+
private static string? GetConnectionStringValue(string connectionString, string key)
660+
{
661+
foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
662+
{
663+
int separatorIndex = segment.IndexOf('=');
664+
if (separatorIndex <= 0)
665+
{
666+
continue;
667+
}
668+
669+
string currentKey = segment[..separatorIndex];
670+
if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase))
671+
{
672+
continue;
673+
}
674+
675+
return segment[(separatorIndex + 1)..];
676+
}
677+
678+
return null;
679+
}
626680
}

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 4 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,7 @@
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>
5658

5759
<!-- Microsoft Clarity - Will be activated based on consent -->
5860
<script type="text/javascript">
@@ -190,6 +192,8 @@
190192
window.REFERRAL_ID = @Json.Serialize(ViewBag.ReferralId);
191193
window.IS_AUTHENTICATED = @Json.Serialize(SignInManager.IsSignedIn(User));
192194
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
195+
window.APPLICATIONINSIGHTS_CONNECTION_STRING = @Json.Serialize(Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]);
196+
window.AUTHENTICATED_USER_ID = @Json.Serialize(User.FindFirstValue(ClaimTypes.NameIdentifier));
193197
window.BUILD_LABEL = @Json.Serialize(buildLabel);
194198
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
195199
</script>
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
const userId = window.AUTHENTICATED_USER_ID;
29+
return typeof userId === "string" && userId.trim().length > 0 ? userId.trim() : null;
30+
}
31+
32+
function setAuthenticatedContext() {
33+
if (!appInsights) {
34+
return;
35+
}
36+
37+
const userId = getAuthenticatedUserId();
38+
if (userId) {
39+
appInsights.setAuthenticatedUserContext(userId);
40+
} else if (typeof appInsights.clearAuthenticatedUserContext === "function") {
41+
appInsights.clearAuthenticatedUserContext();
42+
}
43+
}
44+
45+
function clearAuthenticatedContext() {
46+
if (appInsights && typeof appInsights.clearAuthenticatedUserContext === "function") {
47+
appInsights.clearAuthenticatedUserContext();
48+
}
49+
}
50+
51+
function ensureSdkLoaded() {
52+
if (window.Microsoft?.ApplicationInsights?.ApplicationInsights) {
53+
return Promise.resolve();
54+
}
55+
if (sdkLoadPromise) {
56+
return sdkLoadPromise;
57+
}
58+
59+
sdkLoadPromise = new Promise((resolve, reject) => {
60+
const existing = document.querySelector(`script[src="${SDK_URL}"]`);
61+
if (existing) {
62+
existing.addEventListener("load", () => resolve(), { once: true });
63+
existing.addEventListener("error", () => reject(new Error("Failed to load App Insights SDK.")), { once: true });
64+
return;
65+
}
66+
67+
const script = document.createElement("script");
68+
script.src = SDK_URL;
69+
script.async = true;
70+
script.defer = true;
71+
script.onload = () => resolve();
72+
script.onerror = () => reject(new Error("Failed to load App Insights SDK."));
73+
document.head.appendChild(script);
74+
});
75+
76+
return sdkLoadPromise;
77+
}
78+
79+
function createAppInsights() {
80+
const connectionString = getConnectionString();
81+
if (!connectionString) {
82+
return null;
83+
}
84+
if (!window.Microsoft?.ApplicationInsights?.ApplicationInsights) {
85+
return null;
86+
}
87+
88+
const instance = new window.Microsoft.ApplicationInsights.ApplicationInsights({
89+
config: {
90+
connectionString,
91+
disableAjaxTracking: true, // avoid duplicate/debatable dependency telemetry from browser fetch/XHR
92+
disableTelemetry: false
93+
}
94+
});
95+
96+
instance.loadAppInsights();
97+
setAuthenticatedContext();
98+
99+
if (!didInitialPageView) {
100+
instance.trackPageView();
101+
didInitialPageView = true;
102+
}
103+
104+
return instance;
105+
}
106+
107+
function onConsentGranted() {
108+
const connectionString = getConnectionString();
109+
if (!connectionString) {
110+
return;
111+
}
112+
113+
ensureSdkLoaded()
114+
.then(() => {
115+
if (!appInsights) {
116+
appInsights = createAppInsights();
117+
window.ecsAppInsights = appInsights;
118+
} else {
119+
appInsights.config.disableTelemetry = false;
120+
setAuthenticatedContext();
121+
}
122+
})
123+
.catch((error) => {
124+
console.warn("Application Insights SDK initialization failed:", error);
125+
});
126+
}
127+
128+
function onConsentRevoked() {
129+
if (!appInsights) {
130+
return;
131+
}
132+
133+
clearAuthenticatedContext();
134+
appInsights.config.disableTelemetry = true;
135+
}
136+
137+
function syncConsentState() {
138+
if (hasAnalyticsConsent()) {
139+
onConsentGranted();
140+
} else {
141+
onConsentRevoked();
142+
}
143+
}
144+
145+
function getCurrentTraceId() {
146+
const traceId = appInsights?.context?.telemetryTrace?.traceID;
147+
if (typeof traceId === "string" && /^[a-f0-9]{32}$/i.test(traceId)) {
148+
return traceId.toLowerCase();
149+
}
150+
return null;
151+
}
152+
153+
window.ecsGetAppInsights = function () {
154+
return appInsights;
155+
};
156+
157+
window.ecsGetCorrelationContext = function () {
158+
return getCurrentTraceId();
159+
};
160+
161+
function init() {
162+
window.addEventListener(CONSENT_EVENT, syncConsentState);
163+
syncConsentState();
164+
}
165+
166+
if (document.readyState === "loading") {
167+
document.addEventListener("DOMContentLoaded", init, { once: true });
168+
} else {
169+
init();
170+
}
171+
})();

EssentialCSharp.Web/wwwroot/js/consent-manager.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class ConsentManager {
3636
if (this.shouldShowConsentBanner()) {
3737
this.showConsentBanner();
3838
}
39+
40+
this.notifyConsentChanged();
3941
}
4042

4143
initGoogleConsentMode() {
@@ -259,6 +261,7 @@ class ConsentManager {
259261
console.warn('Failed to update Google Consent Mode:', error);
260262
}
261263
}
264+
this.notifyConsentChanged();
262265
}
263266

264267
updateClarityConsent() {
@@ -424,10 +427,20 @@ class ConsentManager {
424427
return this.consentState.analytics_storage === 'granted';
425428
}
426429

430+
getConsentState() {
431+
return { ...this.consentState };
432+
}
433+
427434
hasAdvertisingConsent() {
428435
return this.consentState.ad_storage === 'granted';
429436
}
430437

438+
notifyConsentChanged() {
439+
window.dispatchEvent(new CustomEvent('ecs:consent-changed', {
440+
detail: { consentState: { ...this.consentState } }
441+
}));
442+
}
443+
431444
// Method to revoke consent (useful for "forget me" functionality)
432445
revokeAllConsent() {
433446
this.rejectAllConsent();
@@ -476,4 +489,11 @@ window.openConsentPreferences = function() {
476489
if (window.consentManager) {
477490
window.consentManager.openConsentPreferences();
478491
}
479-
};
492+
};
493+
494+
window.getEcsConsentState = function() {
495+
if (window.consentManager && typeof window.consentManager.getConsentState === 'function') {
496+
return window.consentManager.getConsentState();
497+
}
498+
return null;
499+
};

0 commit comments

Comments
 (0)