Skip to content

feat: Add Application Insights browser usage telemetry - #1118

Merged
BenjaminMichaelis merged 6 commits into
mainfrom
feat/app-insights-usage-telemetry
May 16, 2026
Merged

BenjaminMichaelis merged 6 commits into
mainfrom
feat/app-insights-usage-telemetry

Conversation

@BenjaminMichaelis

@BenjaminMichaelis BenjaminMichaelis commented May 16, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements Azure Monitor Application Insights dual-instrumentation per Microsoft's usage analysis docs.

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)

Copilot AI review requested due to automatic review settings May 16, 2026 17:54
- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js consent-aware loader plus ecs:consent-changed event from consent-manager.js.
  • trydotnet-module.js fires TryCodeRunner*/TryCodeRun* custom events and passes a correlation context to the Try .NET SDK.
  • Program.cs adds OTel EnrichWithHttpRequest enduser id tagging and CSP entries; _Layout.cshtml exposes APPLICATIONINSIGHTS_CONNECTION_STRING and AUTHENTICATED_USER_ID globals 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 use TryCodeRunRequested and TryCodeRunCompleted (without "Runner"). The PR description further refers to these as TryCodeRunnerRequested and TryCodeRunnerCompleted. 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() reads appInsights?.context?.telemetryTrace?.traceID. At the time useTryDotNet()/the configuration object is built, App Insights may not yet be initialized (the SDK is loaded asynchronously after consent), so this commonly returns null and correlationContext is set to null in the Try .NET session configuration. Even when AI is initialized, this trace id is captured once at session-init time and not refreshed per run(), 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 moment trackTryEvent('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 calls notifyConsentChanged() unconditionally on load, and appinsights-manager.js also calls syncConsentState() directly in its own init(). Depending on script ordering and DOMContentLoaded timing, the appinsights manager can both (a) receive the consent-manager's startup event and (b) run its own syncConsentState, resulting in two concurrent onConsentGranted invocations. The !appInsights guard inside the .then callback prevents double-construction, but two ensureSdkLoaded() chains run in parallel and either both call createAppInsights() (if both .then callbacks resolve before appInsights is 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 in init()).
    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() deletes ai_user/ai_session cookies on every page load when consent is not granted (since syncConsentState() runs on init). The expiration string lacks the SameSite attribute used elsewhere by App Insights when it sets these cookies, and the loop sets domain=${hostname} and domain=.${hostname} only — for an apex domain such as essentialcsharp.com this works, but if the SDK originally wrote the cookie on a parent domain (e.g., user navigated from www. to apex or vice versa), only one of the variants will match. Consider iterating progressively shorter parent domains as consent-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}`;
        });

Comment thread EssentialCSharp.Web/wwwroot/js/trydotnet-module.js
Comment thread EssentialCSharp.Web/wwwroot/js/appinsights-manager.js
Comment thread EssentialCSharp.Web/Program.cs Outdated
Comment thread EssentialCSharp.Web/Program.cs Outdated
Comment thread EssentialCSharp.Web/Views/Shared/_Layout.cshtml Outdated
- 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.
@BenjaminMichaelis
BenjaminMichaelis force-pushed the feat/app-insights-usage-telemetry branch from 378a885 to f342f7a Compare May 16, 2026 17:58
@BenjaminMichaelis BenjaminMichaelis self-assigned this May 16, 2026
- 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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ecsAppInsights is only assigned the first time an instance is created (inside the if (!appInsights) branch). When consent is revoked and later re-granted, appInsights is reused (else-branch only flips disableTelemetry), which is fine — but if createAppInsights() returns null (e.g., SDK loaded but no connection string at that moment), window.ecsAppInsights will be set to null, and the unconditional assignment overwrites any prior valid instance reference that might have been published elsewhere. Consider only assigning window.ecsAppInsights when appInsights is non-null, or removing this global entirely since window.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 ; between path=/ and the Secure attribute. Because secure is initialized as ";Secure" (with a leading semicolon) this happens to render correctly today, but the intent is non-obvious and a future edit that changes secure to "Secure" (without leading ;) would silently produce an invalid path=/Secure attribute. Consider including the separator in the template (e.g. path=/;${secure} with secure being 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}`;
        });

Comment thread EssentialCSharp.Web/wwwroot/js/trydotnet-module.js Outdated
Comment thread EssentialCSharp.Web/wwwroot/js/appinsights-manager.js
Comment thread EssentialCSharp.Web/Program.cs
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread EssentialCSharp.Web/Program.cs Outdated
Comment thread EssentialCSharp.Web/wwwroot/js/appinsights-manager.js Outdated
Comment thread EssentialCSharp.Web/wwwroot/js/consent-manager.js
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Comment thread EssentialCSharp.Web/Program.cs
Comment thread EssentialCSharp.Web/Views/Shared/_Layout.cshtml
Comment thread EssentialCSharp.Web/wwwroot/js/appinsights-manager.js
Comment thread EssentialCSharp.Web/wwwroot/js/trydotnet-module.js
- 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)
@BenjaminMichaelis
BenjaminMichaelis requested a review from Copilot May 16, 2026 22:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@BenjaminMichaelis
BenjaminMichaelis merged commit ca6b88a into main May 16, 2026
12 checks passed
@BenjaminMichaelis
BenjaminMichaelis deleted the feat/app-insights-usage-telemetry branch May 16, 2026 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants