feat(backend,web): App Insights telemetry for optional Graph add-on feature

So we can answer "which tenants actively have this on" and "how many people are
using it" later — without bolting on a separate metrics store.

Backend events
- `GraphAddOnPermissionsDetected` (GraphFeatureDetector, fresh-acquire path):
  fires at most every ~55min per tenant (cache TTL). Props: TenantId,
  GrantedRoleCount, HasScriptDisplayNames, TokenCacheTtlMinutes. → distinct
  count over time = tenants who have the optional permission turned on.
- `ScriptDisplayNamesResolved` (ScriptDisplayNameResolver, only when permission
  is active and the resolver actually did work): per session-view with scripts.
  Props: TenantId, RefsRequested, RefsResolved, CacheHits, TriggeredFullPull.
  → real usage pulse.
- `GraphAddOnStatusChecked` (GET status endpoint): admin engagement with the
  capability page. Props: TenantId, UserId, IsTransient, ScriptDisplayNamesGranted.
- `GraphAddOnRefreshTriggered` (POST refresh endpoint): high-signal "tenant just
  granted" event. Props: TenantId, UserId.

Web events (via `trackEvent` -- tenantId auto-attached by the telemetry initializer)
- `GraphAddOnStatusViewed` on Settings → Optional Graph capabilities mount.
- `GraphAddOnGrantCommandCopied` on Copy command / Copy permissions click.

Notes
- All emits wrapped in try/catch with debug-level logging — telemetry failures
  must never poison the acquire / resolve hot paths.
- Tests pass a no-op `TelemetryClient(new TelemetryConfiguration())` (same
  pattern as SlaTenantStatusLifecycleTests). 1718 backend / 364 web tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-19 01:23:21 +02:00
parent e4c4dd57db
commit 863291ff7b
6 changed files with 138 additions and 6 deletions
@@ -4,6 +4,8 @@ using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
@@ -246,6 +248,7 @@ public class ScriptDisplayNameResolverTests
Sut = new ScriptDisplayNameResolver(
Detector, Repo, HttpFactory,
NullLogger<ScriptDisplayNameResolver>.Instance,
new TelemetryClient(new TelemetryConfiguration()),
new FakeTimeProvider(FixedNow));
}
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
@@ -38,15 +41,18 @@ public class GetGraphPermissionsStatusFunction
private readonly ILogger<GetGraphPermissionsStatusFunction> _logger;
private readonly IGraphFeatureDetector _detector;
private readonly IConfiguration _configuration;
private readonly TelemetryClient _telemetry;
public GetGraphPermissionsStatusFunction(
ILogger<GetGraphPermissionsStatusFunction> logger,
IGraphFeatureDetector detector,
IConfiguration configuration)
IConfiguration configuration,
TelemetryClient telemetry)
{
_logger = logger;
_detector = detector;
_configuration = configuration;
_telemetry = telemetry;
}
[Function("GetGraphPermissionsStatus")]
@@ -79,6 +85,8 @@ public class GetGraphPermissionsStatusFunction
requiredPermissions = GraphFeatureCatalog.RequiredPermissions(featureName),
}).ToList();
EmitStatusChecked(requestCtx.TargetTenantId, requestCtx.UserPrincipalName, snapshot);
return await req.OkAsync(new
{
clientId = _configuration["EntraId:ClientId"] ?? string.Empty,
@@ -93,4 +101,24 @@ public class GetGraphPermissionsStatusFunction
$"Get graph-permissions status for tenant '{tenantId}'");
}
}
private void EmitStatusChecked(string tenantId, string userPrincipalName, GraphPermissionSnapshot snapshot)
{
try
{
_telemetry.TrackEvent("GraphAddOnStatusChecked", new Dictionary<string, string>
{
["TenantId"] = tenantId,
["UserId"] = userPrincipalName,
["IsTransient"] = snapshot.IsTransient.ToString(CultureInfo.InvariantCulture),
["ScriptDisplayNamesGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureScriptDisplayNames, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
{
_logger.LogDebug(ex, "GraphAddOnStatusChecked telemetry emit failed");
}
}
}
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -18,13 +20,16 @@ public class RefreshGraphPermissionsFunction
{
private readonly ILogger<RefreshGraphPermissionsFunction> _logger;
private readonly IGraphFeatureDetector _detector;
private readonly TelemetryClient _telemetry;
public RefreshGraphPermissionsFunction(
ILogger<RefreshGraphPermissionsFunction> logger,
IGraphFeatureDetector detector)
IGraphFeatureDetector detector,
TelemetryClient telemetry)
{
_logger = logger;
_detector = detector;
_telemetry = telemetry;
}
[Function("RefreshGraphPermissions")]
@@ -39,6 +44,22 @@ public class RefreshGraphPermissionsFunction
_detector.InvalidateTenant(requestCtx.TargetTenantId);
_logger.LogInformation("Graph-permissions cache invalidated for tenant {Tenant} by request",
requestCtx.TargetTenantId);
try
{
// High-signal event: admin just ran the grant script and asked us to pick
// up the new appRoleAssignment. Pulses cluster around tenants newly opting in.
_telemetry.TrackEvent("GraphAddOnRefreshTriggered", new Dictionary<string, string>
{
["TenantId"] = requestCtx.TargetTenantId,
["UserId"] = requestCtx.UserPrincipalName,
});
}
catch (Exception ex)
{
_logger.LogDebug(ex, "GraphAddOnRefreshTriggered telemetry emit failed");
}
return await req.OkAsync(new { success = true });
}
catch (Exception ex)
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -24,13 +27,15 @@ public sealed class GraphFeatureDetector : IGraphFeatureDetector
private readonly GraphTokenService _tokenService;
private readonly IMemoryCache _cache;
private readonly ILogger<GraphFeatureDetector> _logger;
private readonly TelemetryClient _telemetry;
private readonly TimeProvider _time;
public GraphFeatureDetector(
GraphTokenService tokenService,
IMemoryCache cache,
ILogger<GraphFeatureDetector> logger)
: this(tokenService, cache, logger, TimeProvider.System)
ILogger<GraphFeatureDetector> logger,
TelemetryClient telemetry)
: this(tokenService, cache, logger, telemetry, TimeProvider.System)
{
}
@@ -39,11 +44,13 @@ public sealed class GraphFeatureDetector : IGraphFeatureDetector
GraphTokenService tokenService,
IMemoryCache cache,
ILogger<GraphFeatureDetector> logger,
TelemetryClient telemetry,
TimeProvider time)
{
_tokenService = tokenService;
_cache = cache;
_logger = logger;
_telemetry = telemetry;
_time = time;
}
@@ -127,9 +134,34 @@ public sealed class GraphFeatureDetector : IGraphFeatureDetector
_logger.LogDebug(
"GraphFeatureDetector: cached token for tenant {TenantId} with {RoleCount} roles, TTL={Ttl}",
tenantId, roles.Count, ttl);
// Telemetry: fires only on FRESH acquire (cache miss). Frequency is bounded by the
// cache TTL (~55 min per tenant), so this is the right pulse for "which tenants
// currently have the optional Graph permissions on" without spamming AppInsights.
EmitPermissionsDetected(tenantId, roles, ttl);
return (ctx, false);
}
private void EmitPermissionsDetected(string tenantId, IReadOnlySet<string> roles, TimeSpan ttl)
{
try
{
_telemetry.TrackEvent("GraphAddOnPermissionsDetected", new Dictionary<string, string>
{
["TenantId"] = tenantId,
["GrantedRoleCount"] = roles.Count.ToString(CultureInfo.InvariantCulture),
["HasScriptDisplayNames"] = roles.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll)
.ToString(CultureInfo.InvariantCulture),
["TokenCacheTtlMinutes"] = ((int)ttl.TotalMinutes).ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
{
// Never let a telemetry failure poison the acquire hot path.
_logger.LogDebug(ex, "GraphAddOnPermissionsDetected telemetry emit failed");
}
}
public void InvalidateTenant(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId)) return;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -8,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
@@ -42,14 +44,16 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
private readonly IScriptNameCacheRepository _cache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ScriptDisplayNameResolver> _logger;
private readonly TelemetryClient _telemetry;
private readonly TimeProvider _time;
public ScriptDisplayNameResolver(
IGraphFeatureDetector detector,
IScriptNameCacheRepository cache,
IHttpClientFactory httpClientFactory,
ILogger<ScriptDisplayNameResolver> logger)
: this(detector, cache, httpClientFactory, logger, TimeProvider.System)
ILogger<ScriptDisplayNameResolver> logger,
TelemetryClient telemetry)
: this(detector, cache, httpClientFactory, logger, telemetry, TimeProvider.System)
{
}
@@ -59,12 +63,14 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
IScriptNameCacheRepository cache,
IHttpClientFactory httpClientFactory,
ILogger<ScriptDisplayNameResolver> logger,
TelemetryClient telemetry,
TimeProvider time)
{
_detector = detector;
_cache = cache;
_httpClientFactory = httpClientFactory;
_logger = logger;
_telemetry = telemetry;
_time = time;
}
@@ -113,11 +119,14 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
cached = new Dictionary<ScriptRef, ScriptDisplayNameEntry>();
}
var cacheHits = 0;
foreach (var kv in cached)
{
if (!kv.Value.IsNotFound) result[kv.Key] = kv.Value.DisplayName;
// NotFound rows: leave result[ref] = null (already initialised).
cacheHits++;
}
var triggeredFullPull = false;
var missing = refs.Where(r => !cached.ContainsKey(r)).ToList();
if (missing.Count == 0) return result;
@@ -137,6 +146,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
if (metaStale)
{
triggeredFullPull = true;
try
{
await FullPullAsync(tenantId, kind, ctx.AccessToken, result, budget).ConfigureAwait(false);
@@ -219,9 +229,35 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
}
}
// Usage telemetry — fires only when the optional permission was active AND the
// resolver actually did work. Volume = once per session-view that has scripts,
// which is a meaningful "tenant is using this feature" pulse.
EmitResolved(tenantId, refs.Count, result, cacheHits, triggeredFullPull);
return result;
}
private void EmitResolved(string tenantId, int refsRequested, Dictionary<ScriptRef, string?> result, int cacheHits, bool triggeredFullPull)
{
try
{
var resolved = 0;
foreach (var v in result.Values) { if (v != null) resolved++; }
_telemetry.TrackEvent("ScriptDisplayNamesResolved", new Dictionary<string, string>
{
["TenantId"] = tenantId,
["RefsRequested"] = refsRequested.ToString(CultureInfo.InvariantCulture),
["RefsResolved"] = resolved.ToString(CultureInfo.InvariantCulture),
["CacheHits"] = cacheHits.ToString(CultureInfo.InvariantCulture),
["TriggeredFullPull"] = triggeredFullPull.ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ScriptDisplayNamesResolved telemetry emit failed");
}
}
// ── Graph HTTP plumbing ──────────────────────────────────────────────────
private async Task FullPullAsync(
@@ -5,6 +5,7 @@ import { useAuth } from "../../../../contexts/AuthContext";
import { useTenant } from "../../../../contexts/TenantContext";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
interface FeatureStatus {
name: string;
@@ -44,6 +45,15 @@ export function SectionOptionalGraphCapabilities() {
}
const body = (await response.json()) as StatusResponse;
setStatus(body);
// Tracks distinct admins/tenants looking at the capability page. The
// backend's GraphAddOnStatusChecked event covers the same signal server-side
// (more authoritative), but the client-side flavour also captures admins who
// bounce off the page before the round-trip lands.
trackEvent("GraphAddOnStatusViewed", {
isTransient: !!body.isTransient,
scriptDisplayNamesGranted: !!body.features.find(f => f.name === "ScriptDisplayNames")?.granted,
grantedRoleCount: body.grantedRoles?.length ?? 0,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load status.");
setStatus(null);
@@ -82,6 +92,8 @@ export function SectionOptionalGraphCapabilities() {
await navigator.clipboard.writeText(text);
setCopied(label);
window.setTimeout(() => setCopied(null), 2000);
// High-intent signal: admin is about to run (or share) the grant command.
trackEvent("GraphAddOnGrantCommandCopied", { kind: label });
} catch {
// Older browsers / iframes — best effort only.
}