Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 68 additions & 3 deletions EssentialCSharp.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,27 @@ private static void Main(string[] args)
// Health probe paths excluded from tracing unconditionally — applies to both
// manual instrumentation and Azure Monitor's auto-instrumentation.
builder.Services.Configure<AspNetCoreTraceInstrumentationOptions>(options =>
{
options.Filter = ctx =>
!ctx.Request.Path.StartsWithSegments("/health")
&& !ctx.Request.Path.StartsWithSegments("/alive"));
&& !ctx.Request.Path.StartsWithSegments("/alive");
// EnrichWithHttpResponse fires after the authentication middleware has run,
// so HttpContext.User is populated and IsAuthenticated is reliable.
options.EnrichWithHttpResponse = (activity, response) =>
Comment thread
BenjaminMichaelis marked this conversation as resolved.
{
var user = response.HttpContext.User;
if (user?.Identity?.IsAuthenticated != true)
{
return;
}

string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (!string.IsNullOrWhiteSpace(userId))
{
activity.SetTag("enduser.id", userId);
}
};
});

var otel = builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
Expand Down Expand Up @@ -492,11 +510,11 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(

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

[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.")]
private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger<Program> logger, string platform);

[LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")]
private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint);

private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return string.Empty;
}

string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint");
if (string.IsNullOrWhiteSpace(ingestionEndpoint)
|| !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri)
|| ingestionUri.Scheme != Uri.UriSchemeHttps)
{
if (logger is not null)
{
LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint);
}
return string.Empty;
}

return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}";
}

private static string? GetConnectionStringValue(string connectionString, string key)
{
foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int separatorIndex = segment.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}

string currentKey = segment[..separatorIndex];
if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase))
{
continue;
}

return segment[(separatorIndex + 1)..].Trim('"');
}

return null;
}
Comment thread
BenjaminMichaelis marked this conversation as resolved.
}
12 changes: 12 additions & 0 deletions EssentialCSharp.Web/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@using EssentialCSharp.Web.Extensions
@using System.Globalization
@using System.Security.Claims
@using EssentialCSharp.Web.Services
@using IntelliTect.Multitool
@using EssentialCSharp.Common
Expand Down Expand Up @@ -53,6 +54,16 @@
<meta name="theme-color" content="#ffffff">
<!-- Cookie Consent Manager - Load before analytics -->
<script src="~/js/consent-manager.js" asp-append-version="true"></script>
<script src="~/js/appinsights-manager.js" asp-append-version="true"></script>
@{
string? authUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
}
@if (!string.IsNullOrEmpty(authUserId))
{
// Scoped to a <meta> tag rather than a window global to avoid exposing the stable
// user GUID to third-party scripts that enumerate window properties.
<meta name="ecs-auth-user-id" content="@authUserId" />
}
Comment thread
BenjaminMichaelis marked this conversation as resolved.

<!-- Microsoft Clarity - Will be activated based on consent -->
<script type="text/javascript">
Expand Down Expand Up @@ -190,6 +201,7 @@
window.REFERRAL_ID = @Json.Serialize(ViewBag.ReferralId);
window.IS_AUTHENTICATED = @Json.Serialize(SignInManager.IsSignedIn(User));
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
window.APPLICATIONINSIGHTS_CONNECTION_STRING = @Json.Serialize(Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]);
window.BUILD_LABEL = @Json.Serialize(buildLabel);
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
</script>
Expand Down
230 changes: 230 additions & 0 deletions EssentialCSharp.Web/wwwroot/js/appinsights-manager.js
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);
});
Comment thread
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();
Comment thread
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);
});
}
Comment thread
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();
}
})();
Loading
Loading