diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj index f54889d8a99..81a4ab3cdb9 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj @@ -16,6 +16,7 @@ + diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Configuration/AzSdkToolsMcpServerConfiguration.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Configuration/AzSdkToolsMcpServerConfiguration.cs new file mode 100644 index 00000000000..050f0791915 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Configuration/AzSdkToolsMcpServerConfiguration.cs @@ -0,0 +1,13 @@ +namespace Azure.Sdk.Tools.Cli.Configuration +{ + public class AzSdkToolsMcpServerConfiguration + { + public const string DefaultName = Constants.TOOLS_ACTIVITY_SOURCE; + + public string Name { get; set; } = DefaultName; + + public string Version { get; set; } = "1.0.0-dev"; + + public bool IsTelemetryEnabled { get; set; } = true; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Extensions/OpenTelemetryExtensions.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Extensions/OpenTelemetryExtensions.cs new file mode 100644 index 00000000000..fdb9080ec5c --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Extensions/OpenTelemetryExtensions.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Runtime.InteropServices; +using Azure.Sdk.Tools.Cli.Configuration; +using Azure.Sdk.Tools.Cli.Telemetry; +using Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Azure.Sdk.Tools.Cli.Extensions; + +public static class OpenTelemetryExtensions +{ + public static void ConfigureOpenTelemetry(this IServiceCollection services) + { + services.AddOptions() + .Configure(options => + { + var entryAssembly = Assembly.GetEntryAssembly(); + var assemblyName = entryAssembly?.GetName() ?? new AssemblyName(); + if (assemblyName?.Version != null) + { + options.Version = assemblyName.Version.ToString(); + } + + var collectTelemetry = Environment.GetEnvironmentVariable("AZSDKTOOLS_MCP_COLLECT_TELEMETRY"); + options.IsTelemetryEnabled = string.IsNullOrEmpty(collectTelemetry) + || (bool.TryParse(collectTelemetry, out var shouldCollect) && shouldCollect); + }); + + services.AddSingleton(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + services.AddSingleton(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + services.AddSingleton(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + EnableAzureMonitor(services); + } + + public static void ConfigureOpenTelemetryLogger(this ILoggingBuilder builder) + { + builder.AddOpenTelemetry(logger => + { + logger.AddProcessor(new TelemetryLogRecordEraser()); + }); + } + + private static void EnableAzureMonitor(this IServiceCollection services) + { +#if DEBUG + services.AddSingleton(sp => + { + var forwarder = new AzureEventSourceLogForwarder(sp.GetRequiredService()); + forwarder.Start(); + return forwarder; + }); +#endif + + var appInsightsConnectionString = Environment.GetEnvironmentVariable("AZSDKTOOLS_MCP_APPLICATIONINSIGHTS_CONNECTION_STRING"); + if (string.IsNullOrEmpty(appInsightsConnectionString)) + { + return; + } + + services.ConfigureOpenTelemetryTracerProvider((sp, builder) => + { + var serverConfig = sp.GetRequiredService>(); + if (!serverConfig.Value.IsTelemetryEnabled) + { + return; + } + + builder.AddSource(serverConfig.Value.Name); + }); + + services.AddOpenTelemetry() + .ConfigureResource(r => + { + var version = Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(); + + r.AddService(Constants.TOOLS_ACTIVITY_SOURCE, version) + .AddTelemetrySdk(); + }) + .UseAzureMonitorExporter(options => + { +#if DEBUG + options.EnableLiveMetrics = true; + options.Diagnostics.IsLoggingEnabled = true; + options.Diagnostics.IsLoggingContentEnabled = true; +#endif + options.ConnectionString = appInsightsConnectionString; + }); + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs index db18e0f3d51..5488ea37f45 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs @@ -6,10 +6,12 @@ using ModelContextProtocol.Server; using Azure.AI.OpenAI; using Azure.Sdk.Tools.Cli.Commands; +using Azure.Sdk.Tools.Cli.Extensions; using Azure.Sdk.Tools.Cli.Microagents; using Azure.Sdk.Tools.Cli.Helpers; using Azure.Sdk.Tools.Cli.Tools; using Azure.Sdk.Tools.Cli.Services.ClientUpdate; +using Azure.Sdk.Tools.Cli.Telemetry; namespace Azure.Sdk.Tools.Cli.Services { @@ -81,6 +83,10 @@ public static void RegisterCommonServices(IServiceCollection services) return new AzureOpenAIClient(new Uri(ep), credential, options); }); }); + + // Telemetry + services.AddSingleton(); + services.ConfigureOpenTelemetry(); } public static void RegisterInstrumentedMcpTools(IServiceCollection services, string[] args) @@ -108,7 +114,8 @@ public static void RegisterInstrumentedMcpTools(IServiceCollection services, str var loggerFactory = services.GetRequiredService(); var logger = loggerFactory.CreateLogger(toolType); - return new InstrumentedTool(logger, innerTool, toolMethod.Name); + var telemetryService = services.GetRequiredService(); + return new InstrumentedTool(telemetryService, logger, innerTool); })); } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/ITelemetryService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/ITelemetryService.cs new file mode 100644 index 00000000000..79add242d08 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/ITelemetryService.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using ModelContextProtocol.Protocol; + +namespace Azure.Sdk.Tools.Cli.Telemetry; + +public interface ITelemetryService : IDisposable +{ + ValueTask StartActivity(string activityName); + + ValueTask StartActivity(string activityName, Implementation? clientInfo); +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/DefaultMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/DefaultMachineInformationProvider.cs new file mode 100644 index 00000000000..4986c74caf3 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/DefaultMachineInformationProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +/// +/// Default information provider not tied to any platform specification for DevDeviceId. +/// +internal class DefaultMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + /// + /// Returns null. + /// + /// + public override Task GetOrCreateDeviceId() => Task.FromResult(null); +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/IMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/IMachineInformationProvider.cs new file mode 100644 index 00000000000..933e98018ac --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/IMachineInformationProvider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +internal interface IMachineInformationProvider +{ + /// + /// Gets existing or creates the device id. In case the cached id cannot be retrieved, or the + /// newly generated id cannot be cached, a value of null is returned. + /// + Task GetOrCreateDeviceId(); + + /// + /// Gets a hash of the machine's MAC address. + /// + Task GetMacAddressHash(); +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/LinuxMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/LinuxMachineInformationProvider.cs new file mode 100644 index 00000000000..98f1f08c688 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/LinuxMachineInformationProvider.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +[SupportedOSPlatform("linux")] + +internal class LinuxMachineInformationProvider(ILogger logger) : UnixMachineInformationProvider(logger) +{ + private const string PrimaryPathEnvVar = "XDG_CACHE_HOME"; + private const string SecondaryPathSubDirectory = ".cache"; + + /// + /// Gets the base folder for the cache to be stored. + /// The final path should be $HOME\.cache\Microsoft\DeveloperTools. + /// + public override string GetStoragePath() + { + var userDir = Environment.GetEnvironmentVariable(PrimaryPathEnvVar); + + // If this comes back as null or empty/whitespace, try environment variable. + if (string.IsNullOrWhiteSpace(userDir)) + { + var rootPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // If the secondary path is still null/empty/whitespace, then throw as it will lead + // to us caching the data in the wrong directory otherwise. + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new InvalidOperationException("linux: Unable to get UserProfile or $HOME folder."); + } + + userDir = Path.Combine(rootPath, SecondaryPathSubDirectory); + } + + return userDir; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MacOSXMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MacOSXMachineInformationProvider.cs new file mode 100644 index 00000000000..636f2f2b8a1 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MacOSXMachineInformationProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +[SupportedOSPlatform("osx")] +internal class MacOSXMachineInformationProvider(ILogger logger) + : UnixMachineInformationProvider(logger) +{ + /// + /// Retrieves the storage path for application data on macOS. + /// + /// This method determines the appropriate storage directory by first attempting to retrieve the + /// user's profile directory using . If that is unavailable, it + /// falls back to the "HOME" environment variable. If neither is available, an is thrown. The returned path is typically located under the + /// "Library/Application Support" directory within the user's home folder. + /// A string representing the full path to the "Library/Application Support" directory in the user's home folder. + /// + /// Thrown if the user's profile directory and the "HOME" + /// environment variable are both unavailable or invalid. + public override string GetStoragePath() + { + var userDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // If this comes back as null or empty/whitespace, try environment variable. + if (string.IsNullOrWhiteSpace(userDir)) + { + userDir = Environment.GetEnvironmentVariable("HOME"); + } + + if (string.IsNullOrWhiteSpace(userDir)) + { + throw new InvalidOperationException("macOS: Unable to get UserProfile or $HOME folder."); + } + + var subdirectoryPath = Path.Combine(userDir, "Library", "Application Support"); + return subdirectoryPath; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MachineInformationProviderBase.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MachineInformationProviderBase.cs new file mode 100644 index 00000000000..d406bcd665f --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/MachineInformationProviderBase.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +internal abstract class MachineInformationProviderBase(ILogger logger) + : IMachineInformationProvider +{ + protected const string MicrosoftDirectory = "Microsoft"; + protected const string DeveloperToolsDirectory = "DeveloperTools"; + protected const string NotAvailable = "N/A"; + + /// + /// The name of the registry key or file containing device id. + /// + protected const string DeviceId = "deviceid"; + + private static readonly SHA256 sha256 = SHA256.Create(); + + private readonly ILogger _logger = logger; + + /// + /// + /// + public abstract Task GetOrCreateDeviceId(); + + /// + /// + /// + public virtual Task GetMacAddressHash() + { + return Task.Run(() => + { + try + { + var address = GetMacAddress(); + + return address != null + ? HashValue(address) + : NotAvailable; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to calculate MAC address hash."); + return NotAvailable; + } + }); + } + + /// + /// Searches for first network interface card that is up and has a physical address. + /// + /// Hash of the MAC address or if none can be found. + protected virtual string? GetMacAddress() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.OperationalStatus == OperationalStatus.Up && x.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .Select(x => x.GetPhysicalAddress().ToString()) + .FirstOrDefault(x => !string.IsNullOrEmpty(x)); + } + + /// + /// Generates a new device identifier. Conditions that have to be met are: + /// + /// Randomly generated UUID/GUID + /// Follows the 8-4-4-4-12 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + /// Contains only lowercase characters and hyphens + /// + /// + /// The generated device identifier. + protected string GenerateDeviceId() => Guid.NewGuid().ToString("D").ToLowerInvariant(); + + /// + /// Generates a SHA-256 of the given value. + /// + protected string HashValue(string value) + { + var hashInput = sha256.ComputeHash(Encoding.UTF8.GetBytes(value)); + return BitConverter.ToString(hashInput).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/UnixMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/UnixMachineInformationProvider.cs new file mode 100644 index 00000000000..1e0e91e143b --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/UnixMachineInformationProvider.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +internal abstract class UnixMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + private readonly ILogger _logger = logger; + + /// + /// Gets the root folder to cache information to. + /// + /// If there is no folder to persist data in. + public abstract string GetStoragePath(); + + public override async Task GetOrCreateDeviceId() + { + string cachePath; + try + { + cachePath = Path.Combine(GetStoragePath(), MicrosoftDirectory, DeveloperToolsDirectory); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Unable to find folder to cache device id to."); + return null; + } + + var existingValue = await ReadValueFromDisk(cachePath, DeviceId); + if (existingValue != null) + { + return existingValue; + } + + var deviceId = GenerateDeviceId(); + if (await WriteValueToDisk(cachePath, DeviceId, deviceId)) + { + return deviceId; + } + else + { + _logger.LogWarning("Unable to persist deviceId."); + return null; + } + } + + /// + /// Try and write the value to disk. If is null or empty, this method will return false. + /// + /// Directory path to write the file. + /// The name of the file. + /// The value to write in the file. + /// True, if the value was successfully written. + /// + public async virtual Task WriteValueToDisk(string directoryPath, string fileName, string? value) + { + // If the value is not set, return immediately. + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + Directory.CreateDirectory(directoryPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to create directory. {Directory}", directoryPath); + return false; + } + + var fullPath = Path.Combine(directoryPath, fileName); + + try + { + File.Delete(fullPath); + + await File.WriteAllTextAsync(fullPath, value, Encoding.UTF8); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to write {Value} to disk. {FullPath}", value, fullPath); + return false; + } + } + + /// + /// Try and read the value from disk. If is null or empty, this method will return false. + /// + /// Returns a value if the value could be written on disk. Otherwise, false. + public async virtual Task ReadValueFromDisk(string directoryPath, string fileName) + { + var path = Path.Combine(directoryPath, fileName); + + if (!File.Exists(path)) + { + return null; + } + + try + { + var contents = await File.ReadAllTextAsync(path, Encoding.UTF8); + return contents; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to read value from {FullPath}", path); + return null; + } + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/WindowsMachineInformationProvider.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/WindowsMachineInformationProvider.cs new file mode 100644 index 00000000000..f421813b887 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/InformationProvider/WindowsMachineInformationProvider.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; + +[SupportedOSPlatform("windows")] +internal class WindowsMachineInformationProvider(ILogger logger) + : MachineInformationProviderBase(logger) +{ + // Construct the parts necessary to cache the ids in the registry. + // The final path is HKEY_CURRENT_USER/SOFTWARE/Microsoft/DeveloperTools + private const RegistryHive Hive = RegistryHive.CurrentUser; + private const string RegistryPathRoot = $"SOFTWARE\\{MicrosoftDirectory}\\{DeveloperToolsDirectory}"; + + private readonly ILogger _logger = logger; + + public override Task GetOrCreateDeviceId() + { + return Task.Run(() => + { + try + { + if (TryGetRegistryValue(RegistryPathRoot, DeviceId, out var existingDeviceId)) + { + return existingDeviceId; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to fetch {Key} value from {RegistryRoot}.", DeviceId, RegistryPathRoot); + } + + var newDeviceId = GenerateDeviceId(); + + try + { + if (TrySetRegistryValue(RegistryPathRoot, DeviceId, newDeviceId)) + { + return newDeviceId; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to persist {Key} in {RegistryPath}.", DeviceId, RegistryPathRoot); + } + + return newDeviceId; + }); + } + + private static bool TryGetRegistryValue(string registryRoot, string keyName, out string value) + { + using var registry = RegistryKey.OpenBaseKey(Hive, RegistryView.Registry64); + using var key = registry.OpenSubKey(registryRoot, writable: false); + + if (key == null) + { + value = string.Empty; + return false; + } + + var matchingKeyName = key.GetValueNames().SingleOrDefault(x => string.Equals(x, keyName), null); + if (matchingKeyName != null) + { + var existingValue = key.GetValue(matchingKeyName)?.ToString(); + value = existingValue ?? string.Empty; + + return !string.IsNullOrEmpty(existingValue); + } + + value = string.Empty; + return false; + } + + private static bool TrySetRegistryValue(string keyPath, string keyName, string value) + { + using var keyRoot = RegistryKey.OpenBaseKey(Hive, RegistryView.Registry64); + using var key = keyRoot.OpenSubKey(keyPath, writable: true) + ?? keyRoot.CreateSubKey(keyPath, RegistryKeyPermissionCheck.ReadWriteSubTree); + + if (key == null) + { + return false; + } + + key.SetValue(keyName, value, RegistryValueKind.String); + + return true; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryConstants.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryConstants.cs new file mode 100644 index 00000000000..bca0ec9fe21 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryConstants.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Sdk.Tools.Cli.Telemetry; + +internal static class TelemetryConstants +{ + /// + /// Name of tags published. + /// + internal class TagName + { + public const string AzureMcpVersion = "Version"; + public const string ClientName = "ClientName"; + public const string ClientVersion = "ClientVersion"; + public const string DevDeviceId = "DevDeviceId"; + public const string ErrorDetails = "ErrorDetails"; + public const string EventId = "EventId"; + public const string MacAddressHash = "MacAddressHash"; + public const string ToolName = "ToolName"; + public const string ToolArea = "ToolArea"; + public const string ToolArgs = "ToolArgs"; + public const string ToolResponse = "ToolResponse"; + } + + internal class ActivityName + { + public const string CommandExecuted = "CommandExecuted"; + public const string ListToolsHandler = "ListToolsHandler"; + public const string ToolExecuted = "ToolExecuted"; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryLogRecordEraser.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryLogRecordEraser.cs new file mode 100644 index 00000000000..06ae70f3999 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryLogRecordEraser.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OpenTelemetry; +using OpenTelemetry.Logs; + +namespace Azure.Sdk.Tools.Cli.Telemetry; + +/// +/// Prevents emitting telemetry events by OpenTelemetryExporter. Accomplishes this by clearing the log contents +/// sent when calling any log methods on . +/// +internal class TelemetryLogRecordEraser : BaseProcessor +{ + private static readonly IReadOnlyList> EmptyAttributes = new List>().AsReadOnly(); + + public override void OnEnd(LogRecord data) + { + data.Attributes = EmptyAttributes; + data.Body = string.Empty; + data.FormattedMessage = string.Empty; + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryService.cs new file mode 100644 index 00000000000..42bf96e9634 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Telemetry/TelemetryService.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Azure.Sdk.Tools.Cli.Configuration; +using Azure.Sdk.Tools.Cli.Telemetry.InformationProvider; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using static Azure.Sdk.Tools.Cli.Telemetry.TelemetryConstants; + +namespace Azure.Sdk.Tools.Cli.Telemetry; +/// +/// Provides access to services. +/// +internal class TelemetryService : ITelemetryService +{ + private readonly bool _isEnabled; + private readonly List> _tagsList; + private readonly IMachineInformationProvider _informationProvider; + private readonly TaskCompletionSource _isInitialized = new TaskCompletionSource(); + + internal ActivitySource Parent { get; } + + public TelemetryService(IMachineInformationProvider informationProvider, IOptions options) + { + _isEnabled = options.Value.IsTelemetryEnabled; + _tagsList = new List>() + { + new(TagName.AzureMcpVersion, options.Value.Version), + }; + + Parent = new ActivitySource(options.Value.Name, options.Value.Version, _tagsList); + _informationProvider = informationProvider; + + Task.Factory.StartNew(InitializeTagList); + } + + public ValueTask StartActivity(string activityId) => StartActivity(activityId, null); + + public async ValueTask StartActivity(string activityId, Implementation? clientInfo) + { + if (!_isEnabled) + { + return null; + } + + await _isInitialized.Task; + + var activity = Parent.StartActivity(activityId); + + if (activity == null) + { + return activity; + } + + if (clientInfo != null) + { + activity.AddTag(TagName.ClientName, clientInfo.Name) + .AddTag(TagName.ClientVersion, clientInfo.Version); + } + + activity.AddTag(TagName.EventId, Guid.NewGuid().ToString()); + + _tagsList.ForEach(kvp => activity.AddTag(kvp.Key, kvp.Value)); + + return activity; + } + + public void Dispose() + { + } + + private async Task InitializeTagList() + { + try + { + var macAddressHash = await _informationProvider.GetMacAddressHash(); + var deviceId = await _informationProvider.GetOrCreateDeviceId(); + + _tagsList.Add(new(TagName.MacAddressHash, macAddressHash)); + _tagsList.Add(new(TagName.DevDeviceId, deviceId)); + + _isInitialized.SetResult(); + } + catch (Exception ex) + { + _isInitialized.SetException(ex); + } + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/HostServer/TelemetryWrapper.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/HostServer/TelemetryWrapper.cs index 52f72a6e0ed..6bb2c847de5 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/HostServer/TelemetryWrapper.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/HostServer/TelemetryWrapper.cs @@ -3,15 +3,14 @@ using System.Diagnostics; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Azure.Sdk.Tools.Cli.Configuration; using System.Text.Json; +using static Azure.Sdk.Tools.Cli.Telemetry.TelemetryConstants; +using Azure.Sdk.Tools.Cli.Telemetry; namespace Azure.Sdk.Tools.Cli.Tools; -public class InstrumentedTool(ILogger logger, McpServerTool innerTool, string toolName) : DelegatingMcpServerTool(innerTool) +public class InstrumentedTool(ITelemetryService telemetryService, ILogger logger, McpServerTool innerTool) : DelegatingMcpServerTool(innerTool) { - private static readonly ActivitySource source = new(Constants.TOOLS_ACTIVITY_SOURCE); - private readonly JsonSerializerOptions serializerOptions = new() { WriteIndented = false, @@ -19,22 +18,28 @@ public class InstrumentedTool(ILogger logger, McpServerTool innerTool, string to public override async ValueTask InvokeAsync(RequestContext request, CancellationToken ct = default) { - using var activity = source.StartActivity("tool.invoke", ActivityKind.Internal); - if (activity == null) + using var activity = await telemetryService.StartActivity(ActivityName.ToolExecuted, request?.Server?.ClientInfo); + Activity.Current = activity; + if (request?.Params == null || string.IsNullOrEmpty(request.Params.Name)) { - logger.LogError("Null activity created for tool {ToolName}", toolName); + activity?.SetStatus(ActivityStatusCode.Error)?.AddTag(TagName.ErrorDetails, "Cannot call tool with null parameters"); + logger.LogWarning("Tool request or tool name is null or empty"); + return await base.InvokeAsync(request, ct); } try { - activity?.SetTag("name", toolName); - var args = JsonSerializer.Serialize(request.Params?.Arguments, serializerOptions); - activity?.SetTag("args", args); + // Add tool name and arg + activity?.AddTag(TagName.ToolName, request.Params.Name); + var args = JsonSerializer.Serialize(request.Params.Arguments, serializerOptions); + activity?.SetTag(TagName.ToolArgs, args); + // Invoke the tool var result = await base.InvokeAsync(request, ct); + // Tag response in the activity var content = JsonSerializer.Serialize(result.Content); - activity?.SetTag("result", content); + activity?.SetTag(TagName.ToolResponse, content); activity?.SetStatus(ActivityStatusCode.Ok); return result;