-
Notifications
You must be signed in to change notification settings - Fork 8
feat: Add Application Insights browser usage telemetry #1118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4f2488d
feat: add Application Insights browser usage telemetry
BenjaminMichaelis f342f7a
fix: address multi-model review findings in App Insights telemetry
BenjaminMichaelis 8165082
fix: address PR review comments on App Insights telemetry
BenjaminMichaelis 595ee38
fix: address PR review comments - event naming, consent race, CSP log…
BenjaminMichaelis 16f6280
fix: address PR review comments - enduser.id timing, window global, c…
BenjaminMichaelis be9faa8
docs: clarify re-grant page-view suppression and event race tradeoff
BenjaminMichaelis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <meta> 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); | ||
| }); | ||
|
BenjaminMichaelis marked this conversation as resolved.
|
||
|
|
||
| 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(); | ||
|
BenjaminMichaelis marked this conversation as resolved.
|
||
| // 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); | ||
| }); | ||
| } | ||
|
BenjaminMichaelis marked this conversation as resolved.
|
||
|
|
||
| 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(); | ||
| } | ||
| })(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.