Skip to content

Commit e9516f9

Browse files
Add telemetry for AzSdk MCP server tool calls (#12063)
* Add telemetry for AzSdk MCP server tool calls
1 parent f700f4a commit e9516f9

16 files changed

Lines changed: 729 additions & 12 deletions

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<ItemGroup>
1717
<PackageReference Include="Azure.AI.Agents.Persistent" Version="1.0.0" />
1818
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
19+
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
1920
<PackageReference Include="Microsoft.Extensions.Azure" Version="1.12.0" />
2021
<PackageReference Include="Microsoft.Graph" Version="5.79.0" />
2122
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Azure.Sdk.Tools.Cli.Configuration
2+
{
3+
public class AzSdkToolsMcpServerConfiguration
4+
{
5+
public const string DefaultName = Constants.TOOLS_ACTIVITY_SOURCE;
6+
7+
public string Name { get; set; } = DefaultName;
8+
9+
public string Version { get; set; } = "1.0.0-dev";
10+
11+
public bool IsTelemetryEnabled { get; set; } = true;
12+
}
13+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Reflection;
5+
using System.Runtime.InteropServices;
6+
using Azure.Sdk.Tools.Cli.Configuration;
7+
using Azure.Sdk.Tools.Cli.Telemetry;
8+
using Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
9+
using Azure.Monitor.OpenTelemetry.Exporter;
10+
using Microsoft.Extensions.Azure;
11+
using Microsoft.Extensions.DependencyInjection;
12+
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
14+
using OpenTelemetry.Resources;
15+
using OpenTelemetry.Trace;
16+
17+
namespace Azure.Sdk.Tools.Cli.Extensions;
18+
19+
public static class OpenTelemetryExtensions
20+
{
21+
public static void ConfigureOpenTelemetry(this IServiceCollection services)
22+
{
23+
services.AddOptions<AzSdkToolsMcpServerConfiguration>()
24+
.Configure(options =>
25+
{
26+
var entryAssembly = Assembly.GetEntryAssembly();
27+
var assemblyName = entryAssembly?.GetName() ?? new AssemblyName();
28+
if (assemblyName?.Version != null)
29+
{
30+
options.Version = assemblyName.Version.ToString();
31+
}
32+
33+
var collectTelemetry = Environment.GetEnvironmentVariable("AZSDKTOOLS_MCP_COLLECT_TELEMETRY");
34+
options.IsTelemetryEnabled = string.IsNullOrEmpty(collectTelemetry)
35+
|| (bool.TryParse(collectTelemetry, out var shouldCollect) && shouldCollect);
36+
});
37+
38+
services.AddSingleton<ITelemetryService, TelemetryService>();
39+
40+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
41+
{
42+
services.AddSingleton<IMachineInformationProvider, WindowsMachineInformationProvider>();
43+
}
44+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
45+
{
46+
services.AddSingleton<IMachineInformationProvider, MacOSXMachineInformationProvider>();
47+
}
48+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
49+
{
50+
services.AddSingleton<IMachineInformationProvider, LinuxMachineInformationProvider>();
51+
}
52+
else
53+
{
54+
services.AddSingleton<IMachineInformationProvider, DefaultMachineInformationProvider>();
55+
}
56+
57+
EnableAzureMonitor(services);
58+
}
59+
60+
public static void ConfigureOpenTelemetryLogger(this ILoggingBuilder builder)
61+
{
62+
builder.AddOpenTelemetry(logger =>
63+
{
64+
logger.AddProcessor(new TelemetryLogRecordEraser());
65+
});
66+
}
67+
68+
private static void EnableAzureMonitor(this IServiceCollection services)
69+
{
70+
#if DEBUG
71+
services.AddSingleton(sp =>
72+
{
73+
var forwarder = new AzureEventSourceLogForwarder(sp.GetRequiredService<ILoggerFactory>());
74+
forwarder.Start();
75+
return forwarder;
76+
});
77+
#endif
78+
79+
var appInsightsConnectionString = Environment.GetEnvironmentVariable("AZSDKTOOLS_MCP_APPLICATIONINSIGHTS_CONNECTION_STRING");
80+
if (string.IsNullOrEmpty(appInsightsConnectionString))
81+
{
82+
return;
83+
}
84+
85+
services.ConfigureOpenTelemetryTracerProvider((sp, builder) =>
86+
{
87+
var serverConfig = sp.GetRequiredService<IOptions<AzSdkToolsMcpServerConfiguration>>();
88+
if (!serverConfig.Value.IsTelemetryEnabled)
89+
{
90+
return;
91+
}
92+
93+
builder.AddSource(serverConfig.Value.Name);
94+
});
95+
96+
services.AddOpenTelemetry()
97+
.ConfigureResource(r =>
98+
{
99+
var version = Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString();
100+
101+
r.AddService(Constants.TOOLS_ACTIVITY_SOURCE, version)
102+
.AddTelemetrySdk();
103+
})
104+
.UseAzureMonitorExporter(options =>
105+
{
106+
#if DEBUG
107+
options.EnableLiveMetrics = true;
108+
options.Diagnostics.IsLoggingEnabled = true;
109+
options.Diagnostics.IsLoggingContentEnabled = true;
110+
#endif
111+
options.ConnectionString = appInsightsConnectionString;
112+
});
113+
}
114+
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
using ModelContextProtocol.Server;
77
using Azure.AI.OpenAI;
88
using Azure.Sdk.Tools.Cli.Commands;
9+
using Azure.Sdk.Tools.Cli.Extensions;
910
using Azure.Sdk.Tools.Cli.Microagents;
1011
using Azure.Sdk.Tools.Cli.Helpers;
1112
using Azure.Sdk.Tools.Cli.Tools;
1213
using Azure.Sdk.Tools.Cli.Services.ClientUpdate;
14+
using Azure.Sdk.Tools.Cli.Telemetry;
1315

1416
namespace Azure.Sdk.Tools.Cli.Services
1517
{
@@ -83,6 +85,10 @@ public static void RegisterCommonServices(IServiceCollection services)
8385
return new AzureOpenAIClient(new Uri(ep), credential, options);
8486
});
8587
});
88+
89+
// Telemetry
90+
services.AddSingleton<ITelemetryService, TelemetryService>();
91+
services.ConfigureOpenTelemetry();
8692
}
8793

8894
public static void RegisterInstrumentedMcpTools(IServiceCollection services, string[] args)
@@ -110,7 +116,8 @@ public static void RegisterInstrumentedMcpTools(IServiceCollection services, str
110116

111117
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
112118
var logger = loggerFactory.CreateLogger(toolType);
113-
return new InstrumentedTool(logger, innerTool, toolMethod.Name);
119+
var telemetryService = services.GetRequiredService<ITelemetryService>();
120+
return new InstrumentedTool(telemetryService, logger, innerTool);
114121
}));
115122
}
116123
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Diagnostics;
5+
using ModelContextProtocol.Protocol;
6+
7+
namespace Azure.Sdk.Tools.Cli.Telemetry;
8+
9+
public interface ITelemetryService : IDisposable
10+
{
11+
ValueTask<Activity?> StartActivity(string activityName);
12+
13+
ValueTask<Activity?> StartActivity(string activityName, Implementation? clientInfo);
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
7+
8+
/// <summary>
9+
/// Default information provider not tied to any platform specification for DevDeviceId.
10+
/// </summary>
11+
internal class DefaultMachineInformationProvider(ILogger<MachineInformationProviderBase> logger)
12+
: MachineInformationProviderBase(logger)
13+
{
14+
/// <summary>
15+
/// Returns null.
16+
/// </summary>
17+
/// <returns></returns>
18+
public override Task<string?> GetOrCreateDeviceId() => Task.FromResult<string?>(null);
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
5+
6+
internal interface IMachineInformationProvider
7+
{
8+
/// <summary>
9+
/// Gets existing or creates the device id. In case the cached id cannot be retrieved, or the
10+
/// newly generated id cannot be cached, a value of null is returned.
11+
/// </summary>
12+
Task<string?> GetOrCreateDeviceId();
13+
14+
/// <summary>
15+
/// Gets a hash of the machine's MAC address.
16+
/// </summary>
17+
Task<string> GetMacAddressHash();
18+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Runtime.Versioning;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
8+
9+
[SupportedOSPlatform("linux")]
10+
11+
internal class LinuxMachineInformationProvider(ILogger<LinuxMachineInformationProvider> logger) : UnixMachineInformationProvider(logger)
12+
{
13+
private const string PrimaryPathEnvVar = "XDG_CACHE_HOME";
14+
private const string SecondaryPathSubDirectory = ".cache";
15+
16+
/// <summary>
17+
/// Gets the base folder for the cache to be stored.
18+
/// The final path should be $HOME\.cache\Microsoft\DeveloperTools.
19+
/// </summary>
20+
public override string GetStoragePath()
21+
{
22+
var userDir = Environment.GetEnvironmentVariable(PrimaryPathEnvVar);
23+
24+
// If this comes back as null or empty/whitespace, try environment variable.
25+
if (string.IsNullOrWhiteSpace(userDir))
26+
{
27+
var rootPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
28+
29+
// If the secondary path is still null/empty/whitespace, then throw as it will lead
30+
// to us caching the data in the wrong directory otherwise.
31+
if (string.IsNullOrWhiteSpace(rootPath))
32+
{
33+
throw new InvalidOperationException("linux: Unable to get UserProfile or $HOME folder.");
34+
}
35+
36+
userDir = Path.Combine(rootPath, SecondaryPathSubDirectory);
37+
}
38+
39+
return userDir;
40+
}
41+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Runtime.Versioning;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
8+
9+
[SupportedOSPlatform("osx")]
10+
internal class MacOSXMachineInformationProvider(ILogger<MacOSXMachineInformationProvider> logger)
11+
: UnixMachineInformationProvider(logger)
12+
{
13+
/// <summary>
14+
/// Retrieves the storage path for application data on macOS.
15+
/// </summary>
16+
/// <remarks>This method determines the appropriate storage directory by first attempting to retrieve the
17+
/// user's profile directory using <see cref="Environment.SpecialFolder.UserProfile"/>. If that is unavailable, it
18+
/// falls back to the "HOME" environment variable. If neither is available, an <see
19+
/// cref="InvalidOperationException"/> is thrown. The returned path is typically located under the
20+
/// "Library/Application Support" directory within the user's home folder.</remarks>
21+
/// <returns>A string representing the full path to the "Library/Application Support" directory in the user's home folder.
22+
/// </returns>
23+
/// <exception cref="InvalidOperationException">Thrown if the user's profile directory and the "HOME"
24+
/// environment variable are both unavailable or invalid.</exception>
25+
public override string GetStoragePath()
26+
{
27+
var userDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
28+
29+
// If this comes back as null or empty/whitespace, try environment variable.
30+
if (string.IsNullOrWhiteSpace(userDir))
31+
{
32+
userDir = Environment.GetEnvironmentVariable("HOME");
33+
}
34+
35+
if (string.IsNullOrWhiteSpace(userDir))
36+
{
37+
throw new InvalidOperationException("macOS: Unable to get UserProfile or $HOME folder.");
38+
}
39+
40+
var subdirectoryPath = Path.Combine(userDir, "Library", "Application Support");
41+
return subdirectoryPath;
42+
}
43+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Net.NetworkInformation;
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider;
10+
11+
internal abstract class MachineInformationProviderBase(ILogger<MachineInformationProviderBase> logger)
12+
: IMachineInformationProvider
13+
{
14+
protected const string MicrosoftDirectory = "Microsoft";
15+
protected const string DeveloperToolsDirectory = "DeveloperTools";
16+
protected const string NotAvailable = "N/A";
17+
18+
/// <summary>
19+
/// The name of the registry key or file containing device id.
20+
/// </summary>
21+
protected const string DeviceId = "deviceid";
22+
23+
private static readonly SHA256 sha256 = SHA256.Create();
24+
25+
private readonly ILogger<MachineInformationProviderBase> _logger = logger;
26+
27+
/// <summary>
28+
/// <inheritdoc/>
29+
/// </summary>
30+
public abstract Task<string?> GetOrCreateDeviceId();
31+
32+
/// <summary>
33+
/// <inheritdoc/>
34+
/// </summary>
35+
public virtual Task<string> GetMacAddressHash()
36+
{
37+
return Task.Run(() =>
38+
{
39+
try
40+
{
41+
var address = GetMacAddress();
42+
43+
return address != null
44+
? HashValue(address)
45+
: NotAvailable;
46+
}
47+
catch (Exception ex)
48+
{
49+
_logger.LogError(ex, "Unable to calculate MAC address hash.");
50+
return NotAvailable;
51+
}
52+
});
53+
}
54+
55+
/// <summary>
56+
/// Searches for first network interface card that is up and has a physical address.
57+
/// </summary>
58+
/// <returns>Hash of the MAC address or <see cref="NotAvailable"/> if none can be found.</returns>
59+
protected virtual string? GetMacAddress()
60+
{
61+
return NetworkInterface.GetAllNetworkInterfaces()
62+
.Where(x => x.OperationalStatus == OperationalStatus.Up && x.NetworkInterfaceType != NetworkInterfaceType.Loopback)
63+
.Select(x => x.GetPhysicalAddress().ToString())
64+
.FirstOrDefault(x => !string.IsNullOrEmpty(x));
65+
}
66+
67+
/// <summary>
68+
/// Generates a new device identifier. Conditions that have to be met are:
69+
/// <list type="bullet">
70+
/// <item>Randomly generated UUID/GUID</item>
71+
/// <item>Follows the 8-4-4-4-12 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)</item>
72+
/// <item>Contains only lowercase characters and hyphens</item>
73+
/// </list>
74+
/// </summary>
75+
/// <returns>The generated device identifier.</returns>
76+
protected string GenerateDeviceId() => Guid.NewGuid().ToString("D").ToLowerInvariant();
77+
78+
/// <summary>
79+
/// Generates a SHA-256 of the given value.
80+
/// </summary>
81+
protected string HashValue(string value)
82+
{
83+
var hashInput = sha256.ComputeHash(Encoding.UTF8.GetBytes(value));
84+
return BitConverter.ToString(hashInput).Replace("-", string.Empty).ToLowerInvariant();
85+
}
86+
}

0 commit comments

Comments
 (0)