11// Copyright (c) Sbroenne. All rights reserved.
22// Licensed under the MIT License.
33
4- using System . Diagnostics ;
54using System . Reflection ;
5+ using Microsoft . ApplicationInsights ;
66
77namespace Sbroenne . ExcelMcp . McpServer . Telemetry ;
88
99/// <summary>
1010/// Centralized telemetry helper for ExcelMcp MCP Server.
11- /// Provides usage tracking and unhandled exception reporting via OpenTelemetry .
11+ /// Provides usage tracking and unhandled exception reporting via Application Insights SDK .
1212/// </summary>
1313public static class ExcelMcpTelemetry
1414{
15- /// <summary>
16- /// The ActivitySource for creating traces.
17- /// </summary>
18- public static readonly ActivitySource ActivitySource = new ( "ExcelMcp.McpServer" , GetVersion ( ) ) ;
19-
2015 /// <summary>
2116 /// Environment variable to opt-out of telemetry.
2217 /// Set to "true" or "1" to disable telemetry.
@@ -40,9 +35,32 @@ public static class ExcelMcpTelemetry
4035
4136 /// <summary>
4237 /// Unique session ID for correlating telemetry within a single MCP server process.
38+ /// Changes each time the MCP server starts.
4339 /// </summary>
4440 public static readonly string SessionId = Guid . NewGuid ( ) . ToString ( "N" ) [ ..8 ] ;
4541
42+ /// <summary>
43+ /// Stable anonymous user ID based on machine identity.
44+ /// Persists across sessions for the same machine, enabling user-level analytics
45+ /// without collecting personally identifiable information.
46+ /// </summary>
47+ public static readonly string UserId = GenerateAnonymousUserId ( ) ;
48+
49+ /// <summary>
50+ /// Application Insights TelemetryClient for sending Custom Events.
51+ /// Enables Users/Sessions analytics in Azure Portal.
52+ /// </summary>
53+ private static TelemetryClient ? _telemetryClient ;
54+
55+ /// <summary>
56+ /// Sets the TelemetryClient instance for sending Custom Events.
57+ /// Called by Program.cs during startup.
58+ /// </summary>
59+ internal static void SetTelemetryClient ( TelemetryClient client )
60+ {
61+ _telemetryClient = client ;
62+ }
63+
4664 private static bool ? _isEnabled ;
4765
4866 /// <summary>
@@ -89,6 +107,7 @@ public static bool IsOptedOut()
89107
90108 /// <summary>
91109 /// Tracks a tool invocation with usage metrics.
110+ /// Sends Application Insights Custom Event for Users/Sessions analytics.
92111 /// </summary>
93112 /// <param name="toolName">The MCP tool name (e.g., "excel_range")</param>
94113 /// <param name="action">The action performed (e.g., "get-values")</param>
@@ -98,60 +117,72 @@ public static void TrackToolInvocation(string toolName, string action, long dura
98117 {
99118 if ( ! IsEnabled ) return ;
100119
101- using var activity = ActivitySource . StartActivity ( "ToolInvocation" , ActivityKind . Internal ) ;
102- if ( activity == null ) return ;
120+ // Debug mode: write to stderr
121+ if ( IsDebugMode ( ) )
122+ {
123+ Console . Error . WriteLine ( $ "[Telemetry] ToolInvocation: { toolName } .{ action } - { ( success ? "Success" : "Failed" ) } ({ durationMs } ms)") ;
124+ }
125+
126+ // Send Application Insights Custom Event (populates customEvents table)
127+ if ( _telemetryClient != null )
128+ {
129+ var properties = new Dictionary < string , string >
130+ {
131+ { "ToolName" , toolName } ,
132+ { "Action" , action } ,
133+ { "Success" , success . ToString ( ) } ,
134+ { "AppVersion" , GetVersion ( ) }
135+ } ;
103136
104- activity . SetTag ( "tool.name" , toolName ) ;
105- activity . SetTag ( "tool.action" , action ) ;
106- activity . SetTag ( "tool.duration_ms" , durationMs ) ;
107- activity . SetTag ( "tool.success" , success ) ;
108- activity . SetTag ( "session.id" , SessionId ) ;
109- activity . SetTag ( "app.version" , GetVersion ( ) ) ;
137+ var metrics = new Dictionary < string , double >
138+ {
139+ { "DurationMs" , durationMs }
140+ } ;
110141
111- activity . SetStatus ( success ? ActivityStatusCode . Ok : ActivityStatusCode . Error ) ;
142+ _telemetryClient . TrackEvent ( "ToolInvocation" , properties , metrics ) ;
143+ }
112144 }
113145
114146 /// <summary>
115147 /// Tracks an unhandled exception.
116148 /// Only call this for exceptions that escape all catch blocks (true bugs/crashes).
149+ /// Sends Application Insights exception and Custom Event.
117150 /// </summary>
118151 /// <param name="exception">The unhandled exception</param>
119152 /// <param name="source">Source of the exception (e.g., "AppDomain.UnhandledException")</param>
120153 public static void TrackUnhandledException ( Exception exception , string source )
121154 {
122155 if ( ! IsEnabled || exception == null ) return ;
123156
124- using var activity = ActivitySource . StartActivity ( "UnhandledException" , ActivityKind . Internal ) ;
125- if ( activity == null ) return ;
126-
127157 // Redact sensitive data from exception
128- var ( type , message , stackTrace ) = SensitiveDataRedactingProcessor . RedactException ( exception ) ;
129-
130- activity . SetTag ( "exception.type" , type ) ;
131- activity . SetTag ( "exception.message" , message ) ;
132- activity . SetTag ( "exception.source" , source ) ;
133- activity . SetTag ( "session.id" , SessionId ) ;
134- activity . SetTag ( "app.version" , GetVersion ( ) ) ;
158+ var ( type , message , _) = SensitiveDataRedactor . RedactException ( exception ) ;
135159
136- if ( stackTrace != null )
160+ // Debug mode: write to stderr
161+ if ( IsDebugMode ( ) )
137162 {
138- // Truncate stack trace to avoid exceeding limits
139- const int maxStackTraceLength = 4096 ;
140- if ( stackTrace . Length > maxStackTraceLength )
141- {
142- stackTrace = stackTrace [ ..maxStackTraceLength ] + "... [truncated]" ;
143- }
144- activity . SetTag ( "exception.stacktrace" , stackTrace ) ;
163+ Console . Error . WriteLine ( $ "[Telemetry] UnhandledException: { type } - { message } (Source: { source } )") ;
145164 }
146165
147- activity . SetStatus ( ActivityStatusCode . Error , message ) ;
148-
149- // Record as exception event
150- activity . AddEvent ( new ActivityEvent ( "exception" , tags : new ActivityTagsCollection
166+ // Send Application Insights telemetry
167+ if ( _telemetryClient != null )
151168 {
152- { "exception.type" , type } ,
153- { "exception.message" , message }
154- } ) ) ;
169+ // Track as exception in Application Insights (for Failures blade)
170+ _telemetryClient . TrackException ( exception , new Dictionary < string , string >
171+ {
172+ { "Source" , source } ,
173+ { "ExceptionType" , type } ,
174+ { "AppVersion" , GetVersion ( ) }
175+ } ) ;
176+
177+ // Also track as Custom Event (for Users/Sessions analytics)
178+ _telemetryClient . TrackEvent ( "UnhandledException" , new Dictionary < string , string >
179+ {
180+ { "Source" , source } ,
181+ { "ExceptionType" , type } ,
182+ { "Message" , message ?? "Unknown" } ,
183+ { "AppVersion" , GetVersion ( ) }
184+ } ) ;
185+ }
155186 }
156187
157188 /// <summary>
@@ -164,4 +195,28 @@ private static string GetVersion()
164195 ?? Assembly . GetExecutingAssembly ( ) . GetName ( ) . Version ? . ToString ( )
165196 ?? "1.0.0" ;
166197 }
198+
199+ /// <summary>
200+ /// Generates a stable anonymous user ID based on machine identity.
201+ /// Uses a hash of machine name and user profile path to create a consistent
202+ /// identifier that persists across sessions without collecting PII.
203+ /// </summary>
204+ private static string GenerateAnonymousUserId ( )
205+ {
206+ try
207+ {
208+ // Combine machine-specific values that are stable but not personally identifiable
209+ var machineIdentity = $ "{ Environment . MachineName } |{ Environment . UserName } |{ Environment . OSVersion . Platform } ";
210+
211+ // Create a SHA256 hash and take the first 16 characters
212+ var bytes = System . Text . Encoding . UTF8 . GetBytes ( machineIdentity ) ;
213+ var hash = System . Security . Cryptography . SHA256 . HashData ( bytes ) ;
214+ return Convert . ToHexString ( hash ) [ ..16 ] . ToLowerInvariant ( ) ;
215+ }
216+ catch
217+ {
218+ // Fallback to a random ID if machine identity cannot be determined
219+ return Guid . NewGuid ( ) . ToString ( "N" ) [ ..16 ] ;
220+ }
221+ }
167222}
0 commit comments