feat: Add Application Insights browser usage telemetry - #1118
Conversation
- 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
There was a problem hiding this comment.
Pull request overview
Adds Azure Monitor Application Insights browser telemetry to the site, gated by the existing analytics consent flow, and emits custom events around the Try .NET code-runner lifecycle. Server-side, it enriches OTel spans with enduser.id from the authenticated user's NameIdentifier and extends CSP for the AI ingestion endpoints. The layout now exposes the AI connection string and authenticated user id to the page.
Changes:
- New
appinsights-manager.jsconsent-aware loader plusecs:consent-changedevent fromconsent-manager.js. trydotnet-module.jsfiresTryCodeRunner*/TryCodeRun*custom events and passes a correlation context to the Try .NET SDK.Program.csadds OTelEnrichWithHttpRequestenduser id tagging and CSP entries;_Layout.cshtmlexposesAPPLICATIONINSIGHTS_CONNECTION_STRINGandAUTHENTICATED_USER_IDglobals and loads the new script.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| EssentialCSharp.Web/wwwroot/js/trydotnet-module.js | Emits custom AI events around code-runner open/run; forwards correlationContext to Try SDK. |
| EssentialCSharp.Web/wwwroot/js/consent-manager.js | Dispatches ecs:consent-changed, exposes getConsentState, clears ai_user/ai_session cookies. |
| EssentialCSharp.Web/wwwroot/js/appinsights-manager.js | New consent-gated App Insights JS SDK loader with auth context and trace id helper. |
| EssentialCSharp.Web/Views/Shared/_Layout.cshtml | Exposes connection string + authenticated user id globals; loads new manager script. |
| EssentialCSharp.Web/Program.cs | Adds enduser.id OTel tag and AI-related CSP sources, including endpoint parsed from the connection string. |
Comments suppressed due to low confidence (4)
EssentialCSharp.Web/wwwroot/js/trydotnet-module.js:408
- The custom event names are inconsistent: the "Opened" event uses
TryCodeRunnerOpened(with "Runner"), but the run lifecycle events useTryCodeRunRequestedandTryCodeRunCompleted(without "Runner"). The PR description further refers to these asTryCodeRunnerRequestedandTryCodeRunnerCompleted. Pick one prefix (e.g.,TryCodeRunner*) and apply it consistently across all three events so dashboards/queries can filter on a common prefix.
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 }
);
EssentialCSharp.Web/wwwroot/js/appinsights-manager.js:190
getCurrentTraceId()readsappInsights?.context?.telemetryTrace?.traceID. At the timeuseTryDotNet()/the configuration object is built, App Insights may not yet be initialized (the SDK is loaded asynchronously after consent), so this commonly returnsnullandcorrelationContextis set tonullin the Try .NET session configuration. Even when AI is initialized, this trace id is captured once at session-init time and not refreshed perrun(), so all subsequent runs share the same correlation id (which corresponds to the original page view, not the run). Consider either fetching the trace id lazily at the momenttrackTryEvent('TryCodeRunRequested', ...)fires, or generating a new operation id per run.
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();
};
EssentialCSharp.Web/wwwroot/js/appinsights-manager.js:145
ConsentManager.init()now callsnotifyConsentChanged()unconditionally on load, andappinsights-manager.jsalso callssyncConsentState()directly in its owninit(). Depending on script ordering and DOMContentLoaded timing, the appinsights manager can both (a) receive the consent-manager's startup event and (b) run its ownsyncConsentState, resulting in two concurrentonConsentGrantedinvocations. The!appInsightsguard inside the.thencallback prevents double-construction, but twoensureSdkLoaded()chains run in parallel and either both callcreateAppInsights()(if both.thencallbacks resolve beforeappInsightsis set on the first) — there is no synchronization between the two awaited promise resolutions. Consider gating the in-flight grant with a boolean (e.g.,isInitializing), or only sync from the event (not directly ininit()).
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);
});
}
EssentialCSharp.Web/wwwroot/js/appinsights-manager.js:165
onConsentRevoked()deletesai_user/ai_sessioncookies on every page load when consent is not granted (sincesyncConsentState()runs on init). The expiration string lacks theSameSiteattribute used elsewhere by App Insights when it sets these cookies, and the loop setsdomain=${hostname}anddomain=.${hostname}only — for an apex domain such asessentialcsharp.comthis works, but if the SDK originally wrote the cookie on a parent domain (e.g., user navigated fromwww.to apex or vice versa), only one of the variants will match. Consider iterating progressively shorter parent domains asconsent-manager.clearTrackingCookies()already does, and reuse that helper to avoid duplication.
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}`;
});
- 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.
378a885 to
f342f7a
Compare
- 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 <script> element rather than waiting another 15s
- _Layout.cshtml: replace window.AUTHENTICATED_USER_ID global with a
<meta name='ecs-auth-user-id'> tag, scoped to avoid exposing the
stable user GUID to third-party scripts that enumerate window globals
- appinsights-manager.js: getAuthenticatedUserId() reads from meta tag
- Program.cs: validate ingestionUri.Scheme == https before emitting
into CSP header to guard against malformed connection strings
- Program.cs: remove legacy dc.services.visualstudio.com from
connect-src (modern resources use *.in.applicationinsights.azure.com)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
EssentialCSharp.Web/wwwroot/js/appinsights-manager.js:152
window.ecsAppInsightsis only assigned the first time an instance is created (inside theif (!appInsights)branch). When consent is revoked and later re-granted,appInsightsis reused (else-branch only flipsdisableTelemetry), which is fine — but ifcreateAppInsights()returnsnull(e.g., SDK loaded but no connection string at that moment),window.ecsAppInsightswill be set tonull, and the unconditional assignment overwrites any prior valid instance reference that might have been published elsewhere. Consider only assigningwindow.ecsAppInsightswhenappInsightsis non-null, or removing this global entirely sincewindow.ecsGetAppInsights()is already the public accessor and exposing both is redundant.
if (!appInsights) {
appInsights = createAppInsights();
window.ecsAppInsights = appInsights;
} else {
appInsights.config.disableTelemetry = false;
setAuthenticatedContext();
}
EssentialCSharp.Web/wwwroot/js/appinsights-manager.js:177
- The cookie-clear strings build
path=/${secure}without a;betweenpath=/and theSecureattribute. Becausesecureis initialized as";Secure"(with a leading semicolon) this happens to render correctly today, but the intent is non-obvious and a future edit that changessecureto"Secure"(without leading;) would silently produce an invalidpath=/Secureattribute. Consider including the separator in the template (e.g.path=/;${secure}withsecurebeing just"Secure"or empty) to make the dependency on the leading semicolon explicit.
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}`;
});
…ging - 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
…onsent 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
- 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)
Summary
Implements Azure Monitor Application Insights dual-instrumentation per Microsoft's usage analysis docs.
What's added
Browser (client-side)
Custom events (code runner lifecycle)
Server-side
Layout
Privacy / GDPR notes