diff --git a/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs b/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs index 5451f9f..23d1afd 100644 --- a/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs +++ b/src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs @@ -231,7 +231,7 @@ private static async Task TagActiveProfileIdentityAsync(Activity? activity) { var tagger = DependencyInjection.TxcServices.GetOptional(); if (tagger != null) - await tagger.TagFromActiveProfileAsync(activity).ConfigureAwait(false); + await tagger.TagFromProfileAsync(activity, profileName: null, CancellationToken.None).ConfigureAwait(false); } /// diff --git a/src/TALXIS.CLI.Core/Telemetry/ActivityIdentityTagger.cs b/src/TALXIS.CLI.Core/Telemetry/ActivityIdentityTagger.cs index 34221b2..5b9c207 100644 --- a/src/TALXIS.CLI.Core/Telemetry/ActivityIdentityTagger.cs +++ b/src/TALXIS.CLI.Core/Telemetry/ActivityIdentityTagger.cs @@ -15,29 +15,26 @@ namespace TALXIS.CLI.Core.Telemetry; /// public sealed class ActivityIdentityTagger { - private readonly IGlobalConfigStore _configStore; private readonly IConfigurationResolver _resolver; - public ActivityIdentityTagger(IGlobalConfigStore configStore, IConfigurationResolver resolver) + public ActivityIdentityTagger(IConfigurationResolver resolver) { - _configStore = configStore; _resolver = resolver; } /// - /// Tags the Activity with identity from the globally active profile (if any). + /// Tags the Activity using the same profile resolution chain as the CLI: + /// explicit profile (when supplied), then ambient resolver fallbacks such as + /// TXC_PROFILE, workspace pin, and global active profile. /// Best-effort: resolution failures are silently ignored — telemetry never blocks execution. /// - public async Task TagFromActiveProfileAsync(Activity? activity) + public async Task TagFromProfileAsync(Activity? activity, string? profileName, CancellationToken ct) { if (activity == null) return; try { - var config = await _configStore.LoadAsync(CancellationToken.None).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(config.ActiveProfile)) return; - - var context = await _resolver.ResolveAsync(config.ActiveProfile, CancellationToken.None).ConfigureAwait(false); + var context = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false); TagFromResolvedProfile(activity, context.Credential, context.Connection); } catch (Exception) @@ -47,6 +44,12 @@ public async Task TagFromActiveProfileAsync(Activity? activity) } } + /// + /// Tags the Activity with identity from the ambient resolver fallback chain. + /// + public Task TagFromActiveProfileAsync(Activity? activity) + => TagFromProfileAsync(activity, profileName: null, CancellationToken.None); + /// /// Tags the Activity with identity from an already-resolved profile. /// Called by when --profile is specified explicitly. diff --git a/src/TALXIS.CLI.Logging/TxcTelemetryLogProvider.cs b/src/TALXIS.CLI.Logging/TxcTelemetryLogProvider.cs index ce04e70..d5e5a19 100644 --- a/src/TALXIS.CLI.Logging/TxcTelemetryLogProvider.cs +++ b/src/TALXIS.CLI.Logging/TxcTelemetryLogProvider.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; +using TALXIS.CLI.Abstractions; namespace TALXIS.CLI.Logging; @@ -30,6 +31,16 @@ public void Dispose() { } internal sealed class TxcTelemetryLogger : ILogger { + private static readonly string[] CopiedActivityTagKeys = + [ + "enduser.id", + "enduser.name", + "enduser.scope", + "txc.environment_url", + "txc.environment_name", + "txc.error_message", + ]; + public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && Activity.Current != null; @@ -51,13 +62,19 @@ public void Log( // in the App Insights 'exceptions' table with full stack traces. if (exception != null && logLevel >= LogLevel.Error) { + var root = ExceptionHelpers.GetInnermostException(exception); + var redactedErrorMessage = LogRedactionFilter.Redact(root.Message); + SetErrorMessageIfMissing(activity, redactedErrorMessage); + + var eventTags = new ActivityTagsCollection + { + { "exception.type", exception.GetType().FullName }, + { "exception.message", LogRedactionFilter.Redact(exception.Message) }, + { "exception.stacktrace", LogRedactionFilter.Redact(exception.ToString()) }, + }; + CopySelectedActivityTags(activity, eventTags); activity.AddEvent(new ActivityEvent("exception", - tags: new ActivityTagsCollection - { - { "exception.type", exception.GetType().FullName }, - { "exception.message", LogRedactionFilter.Redact(exception.Message) }, - { "exception.stacktrace", LogRedactionFilter.Redact(exception.ToString()) }, - })); + tags: eventTags)); } // For Error/Critical log calls WITHOUT an exception object (e.g., @@ -70,11 +87,32 @@ public void Log( { var msg = formatter(state, null); if (!string.IsNullOrWhiteSpace(msg)) - activity.SetTag("txc.error_message", LogRedactionFilter.Redact(msg)); + SetErrorMessageIfMissing(activity, LogRedactionFilter.Redact(msg), overwrite: true); } // Note: span error status is NOT set here — CommandActivityScope.SetExitCode() // is the sole authority for span status. This avoids double-SetStatus where the // logger's descriptive message gets overwritten by the generic "Exit code N". } + + private static void CopySelectedActivityTags(Activity activity, ActivityTagsCollection target) + { + foreach (var tagKey in CopiedActivityTagKeys) + { + var value = activity.GetTagItem(tagKey); + if (value != null) + target.Add(tagKey, value); + } + } + + private static void SetErrorMessageIfMissing(Activity activity, string errorMessage, bool overwrite = false) + { + if (string.IsNullOrWhiteSpace(errorMessage)) + return; + + if (!overwrite && activity.GetTagItem("txc.error_message") is not null) + return; + + activity.SetTag("txc.error_message", errorMessage); + } } diff --git a/src/TALXIS.CLI.MCP/McpTelemetryEnricher.cs b/src/TALXIS.CLI.MCP/McpTelemetryEnricher.cs new file mode 100644 index 0000000..a5506fc --- /dev/null +++ b/src/TALXIS.CLI.MCP/McpTelemetryEnricher.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Core.Telemetry; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.MCP; + +internal sealed class McpTelemetryEnricher +{ + private static readonly Microsoft.Extensions.Logging.ILogger Logger = + TxcLoggerFactory.CreateLogger(nameof(McpTelemetryEnricher)); + + private readonly ActivityIdentityTagger? _tagger; + private readonly IProfileStore? _profiles; + private readonly IConnectionStore? _connections; + private readonly ICredentialStore? _credentials; + private readonly IGlobalConfigStore? _globalConfig; + private readonly IWorkspaceDiscovery? _workspaceDiscovery; + + public McpTelemetryEnricher( + ActivityIdentityTagger? tagger, + IProfileStore? profiles = null, + IConnectionStore? connections = null, + ICredentialStore? credentials = null, + IGlobalConfigStore? globalConfig = null, + IWorkspaceDiscovery? workspaceDiscovery = null) + { + _tagger = tagger; + _profiles = profiles; + _connections = connections; + _credentials = credentials; + _globalConfig = globalConfig; + _workspaceDiscovery = workspaceDiscovery; + } + + public Task TagActivityAsync( + Activity? activity, + IReadOnlyDictionary? arguments, + string? workingDirectory, + CancellationToken ct) + { + if (activity == null) + return Task.CompletedTask; + + return TagActivityCoreAsync(activity, arguments, workingDirectory, ct); + } + + internal static string? TryGetProfile(IReadOnlyDictionary? arguments) + { + if (arguments == null || !arguments.TryGetValue("profile", out var profileElement)) + return null; + + return profileElement.ValueKind == JsonValueKind.String + ? profileElement.GetString() + : null; + } + + private async Task TagActivityCoreAsync( + Activity activity, + IReadOnlyDictionary? arguments, + string? workingDirectory, + CancellationToken ct) + { + var profileName = TryGetProfile(arguments); + + try + { + if (!string.IsNullOrWhiteSpace(profileName)) + { + if (_tagger != null) + { + await _tagger.TagFromProfileAsync(activity, profileName, ct).ConfigureAwait(false); + return; + } + + var explicitResolver = CreateResolver(workingDirectory); + if (explicitResolver != null) + { + var explicitContext = await explicitResolver.ResolveAsync(profileName, ct).ConfigureAwait(false); + ActivityIdentityTagger.TagFromResolvedProfile(activity, explicitContext.Credential, explicitContext.Connection); + } + return; + } + + if (string.IsNullOrWhiteSpace(workingDirectory)) + { + if (_tagger != null) + await _tagger.TagFromProfileAsync(activity, profileName: null, ct).ConfigureAwait(false); + return; + } + + if (_profiles == null || _connections == null || _credentials == null + || _globalConfig == null || _workspaceDiscovery == null) + return; + + var resolver = CreateResolver(workingDirectory); + if (resolver == null) + return; + + var context = await resolver.ResolveAsync(profileName: null, ct).ConfigureAwait(false); + ActivityIdentityTagger.TagFromResolvedProfile(activity, context.Credential, context.Connection); + } + catch (Exception) + { + // Best-effort — telemetry enrichment must never fail the request path. + Logger.LogDebug("Skipping MCP telemetry identity enrichment."); + } + } + + private ConfigurationResolver? CreateResolver(string? workingDirectory) + { + if (_profiles == null || _connections == null || _credentials == null + || _globalConfig == null || _workspaceDiscovery == null) + return null; + + IEnvironmentReader env = string.IsNullOrWhiteSpace(workingDirectory) + ? ProcessEnvironmentReader.Instance + : new FixedCurrentDirectoryEnvironmentReader(workingDirectory); + + return new ConfigurationResolver( + _profiles, + _connections, + _credentials, + _globalConfig, + _workspaceDiscovery, + env); + } + + private sealed class FixedCurrentDirectoryEnvironmentReader : IEnvironmentReader + { + private readonly string _workingDirectory; + + public FixedCurrentDirectoryEnvironmentReader(string workingDirectory) + { + _workingDirectory = workingDirectory; + } + + public string? Get(string name) => Environment.GetEnvironmentVariable(name); + + public string GetCurrentDirectory() => _workingDirectory; + } +} diff --git a/src/TALXIS.CLI.MCP/Program.cs b/src/TALXIS.CLI.MCP/Program.cs index e5db958..c76cc62 100644 --- a/src/TALXIS.CLI.MCP/Program.cs +++ b/src/TALXIS.CLI.MCP/Program.cs @@ -52,6 +52,13 @@ // Initialize telemetry for the MCP server (fire-and-forget, never blocks startup) InitializeMcpTelemetry(); +var mcpTelemetryEnricher = new McpTelemetryEnricher( + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional(), + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional(), + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional(), + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional(), + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional(), + TALXIS.CLI.Core.DependencyInjection.TxcServices.GetOptional()); try { @@ -366,6 +373,7 @@ async Task ExecuteCliToolAsync( // Capture the MCP Server span before creating the Client span — // Activity.Current will change once the dispatch span starts. var mcpActivity = System.Diagnostics.Activity.Current; + await mcpTelemetryEnricher.TagActivityAsync(mcpActivity, cliArguments, workingDirectory, ct); CliSubprocessResult result; // Client span for the subprocess dispatch — shows as a dependency in App Insights, @@ -378,6 +386,7 @@ async Task ExecuteCliToolAsync( // in the dependency type and Application Map dispatchActivity?.SetTag("peer.service", "talxis-cli"); dispatchActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.Tool, toolName); + await mcpTelemetryEnricher.TagActivityAsync(dispatchActivity, cliArguments, workingDirectory, ct); result = await CliSubprocessRunner.RunAsync(cliArgs, logForwarder, ct, workingDirectory); dispatchActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.SubprocessExitCode, result.ExitCode); @@ -441,6 +450,8 @@ async ValueTask ExecuteAsTaskAsync( // may not be safe to access after the handler returns. var server = ctx.Server; var sessionId = server.SessionId; + var requestActivity = System.Diagnostics.Activity.Current; + var parentActivityContext = requestActivity?.Context; // Use override arguments (from execute_operation) if provided, otherwise extract from ctx var arguments = overrideArguments is not null ? new Dictionary(overrideArguments) @@ -450,6 +461,10 @@ async ValueTask ExecuteAsTaskAsync( var progressToken = ctx.Params?.ProgressToken; var requestId = ctx.JsonRpcRequest.Id; var jsonRpcRequest = ctx.JsonRpcRequest; + string? workingDirectory = rootsService is not null + ? await rootsService.GetWorkingDirectoryAsync(ct) + : null; + await mcpTelemetryEnricher.TagActivityAsync(requestActivity, arguments, workingDirectory, ct); // Create the task in the store var mcpTask = await taskStore.CreateTaskAsync( @@ -468,8 +483,18 @@ async ValueTask ExecuteAsTaskAsync( // All captured variables are locals — no ctx/p references inside the closure. _ = Task.Run(async () => { + using var taskActivity = parentActivityContext.HasValue + ? TxcTelemetry.Source.StartActivity($"task:{toolName}", System.Diagnostics.ActivityKind.Server, parentActivityContext.Value) + : TxcTelemetry.Source.StartActivity($"task:{toolName}", System.Diagnostics.ActivityKind.Server); + + taskActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.Tool, toolName); + taskActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.EntryPoint, TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.EntryPointMcp); + taskActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.Version, typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "unknown"); + try { + await mcpTelemetryEnricher.TagActivityAsync(taskActivity, arguments, workingDirectory, taskCts.Token); + // Mark task as working var workingTask = await taskStore.UpdateTaskStatusAsync( mcpTask.TaskId, McpTaskStatus.Working, null, sessionId, CancellationToken.None); @@ -486,11 +511,31 @@ async ValueTask ExecuteAsTaskAsync( mcpLogger.LogInformation("Starting task-augmented tool: {ToolName} (taskId: {TaskId})", toolName, mcpTask.TaskId); - string? workingDirectory = rootsService is not null - ? await rootsService.GetWorkingDirectoryAsync(taskCts.Token) - : null; + CliSubprocessResult result; + using (var dispatchActivity = TxcTelemetry.Source.StartActivity( + $"subprocess:{toolName}", System.Diagnostics.ActivityKind.Client)) + { + dispatchActivity?.SetTag("peer.service", "talxis-cli"); + dispatchActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.Tool, toolName); + await mcpTelemetryEnricher.TagActivityAsync(dispatchActivity, cliArguments, workingDirectory, taskCts.Token); + result = await CliSubprocessRunner.RunAsync(cliArgs, logForwarder, taskCts.Token, workingDirectory); + dispatchActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.SubprocessExitCode, result.ExitCode); + if (result.ExitCode != 0) + dispatchActivity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, $"Exit code {result.ExitCode}"); + } - CliSubprocessResult result = await CliSubprocessRunner.RunAsync(cliArgs, logForwarder, taskCts.Token, workingDirectory); + taskActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.ExitCode, result.ExitCode); + if (result.ExitCode != 0) + { + taskActivity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, $"Exit code {result.ExitCode}"); + if (!string.IsNullOrWhiteSpace(result.LastErrors)) + { + var firstError = result.LastErrors + .Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault() ?? result.LastErrors.Trim(); + taskActivity?.SetTag(TALXIS.CLI.Core.Telemetry.TxcTelemetryTags.ErrorMessage, firstError); + } + } mcpLogger.LogInformation("Task completed: {ToolName} (exit code {ExitCode}, taskId: {TaskId})", toolName, result.ExitCode, mcpTask.TaskId); @@ -517,6 +562,9 @@ async ValueTask ExecuteAsTaskAsync( { try { + taskActivity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message); + TxcLoggerFactory.CreateLogger($"txc.{toolName}") + .LogError(ex, "Task dispatch failed: {ToolName}", toolName); var errorResult = toolResultFactory.BuildExceptionResult(toolName, ex); var errorElement = System.Text.Json.JsonSerializer.SerializeToElement(errorResult); var failedTask = await taskStore.StoreTaskResultAsync( diff --git a/tests/TALXIS.CLI.Tests/Logging/TxcTelemetryLogProviderTests.cs b/tests/TALXIS.CLI.Tests/Logging/TxcTelemetryLogProviderTests.cs index b7c9cd2..f349525 100644 --- a/tests/TALXIS.CLI.Tests/Logging/TxcTelemetryLogProviderTests.cs +++ b/tests/TALXIS.CLI.Tests/Logging/TxcTelemetryLogProviderTests.cs @@ -25,17 +25,45 @@ public void LogError_WithoutException_StampsRedactedErrorMessageOnCurrentActivit } [Fact] - public void LogError_WithException_RecordsExceptionEventWithoutOverwritingErrorMessage() + public void LogError_WithException_RecordsExceptionEventAndPromotesContext() { using var listener = CreateListener(); using var activity = TxcActivitySource.Instance.StartActivity("workspace_validate"); using var provider = new TxcTelemetryLogProvider(); var logger = provider.CreateLogger("test"); + activity?.SetTag(TxcTelemetryTags.EndUserId, "user-123"); + activity?.SetTag(TxcTelemetryTags.EndUserName, "user@example.com"); + activity?.SetTag(TxcTelemetryTags.EndUserScope, "tenant-456"); + activity?.SetTag(TxcTelemetryTags.EnvironmentName, "Sandbox"); + + logger.LogError( + new InvalidOperationException("outer", new InvalidOperationException("boom")), + "Command failed"); + + Assert.Equal("boom", activity?.GetTagItem(TxcTelemetryTags.ErrorMessage)); + + var exceptionEvent = Assert.Single(activity!.Events, e => e.Name == "exception"); + Assert.Contains(exceptionEvent.Tags, tag => tag.Key == TxcTelemetryTags.EndUserId && Equals(tag.Value, "user-123")); + Assert.Contains(exceptionEvent.Tags, tag => tag.Key == TxcTelemetryTags.EndUserName && Equals(tag.Value, "user@example.com")); + Assert.Contains(exceptionEvent.Tags, tag => tag.Key == TxcTelemetryTags.EndUserScope && Equals(tag.Value, "tenant-456")); + Assert.Contains(exceptionEvent.Tags, tag => tag.Key == TxcTelemetryTags.EnvironmentName && Equals(tag.Value, "Sandbox")); + Assert.Contains(exceptionEvent.Tags, tag => tag.Key == TxcTelemetryTags.ErrorMessage && Equals(tag.Value, "boom")); + } + + [Fact] + public void LogError_WithException_DoesNotOverwriteExistingErrorMessage() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("workspace_validate"); + using var provider = new TxcTelemetryLogProvider(); + var logger = provider.CreateLogger("test"); + + activity?.SetTag(TxcTelemetryTags.ErrorMessage, "existing-message"); + logger.LogError(new InvalidOperationException("boom"), "Command failed"); - Assert.Null(activity?.GetTagItem(TxcTelemetryTags.ErrorMessage)); - Assert.Contains(activity!.Events, e => e.Name == "exception"); + Assert.Equal("existing-message", activity?.GetTagItem(TxcTelemetryTags.ErrorMessage)); } private static ActivityListener CreateListener() diff --git a/tests/TALXIS.CLI.Tests/MCP/McpTelemetryEnricherTests.cs b/tests/TALXIS.CLI.Tests/MCP/McpTelemetryEnricherTests.cs new file mode 100644 index 0000000..933247d --- /dev/null +++ b/tests/TALXIS.CLI.Tests/MCP/McpTelemetryEnricherTests.cs @@ -0,0 +1,257 @@ +using System.Diagnostics; +using System.Text.Json; +using TALXIS.CLI.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Telemetry; +using TALXIS.CLI.MCP; +using Xunit; + +namespace TALXIS.CLI.Tests.MCP; + +public class McpTelemetryEnricherTests +{ + [Fact] + public async Task TagActivityAsync_WithExplicitProfile_DelegatesToIdentityTagger() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("execute_operation"); + + var resolver = new FakeConfigurationResolver(_ => CreateResolvedProfileContext()); + var enricher = new McpTelemetryEnricher(new ActivityIdentityTagger(resolver)); + var arguments = new Dictionary + { + ["profile"] = JsonDocument.Parse("\"custom-profile\"").RootElement.Clone() + }; + + await enricher.TagActivityAsync(activity, arguments, workingDirectory: null, CancellationToken.None); + + Assert.Single(resolver.RequestedProfiles); + Assert.Equal("custom-profile", resolver.RequestedProfiles[0]); + Assert.Equal("user@example.com", activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + } + + [Fact] + public async Task TagActivityAsync_WithExplicitProfile_FallsBackToStoresWhenTaggerMissing() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("execute_operation"); + + var profile = new Profile { Id = "custom-profile", ConnectionRef = "conn", CredentialRef = "cred" }; + var connection = new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + DisplayName = "Contoso Sandbox", + TenantId = "tenant-123" + }; + var credential = new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + InteractiveUpn = "user@example.com" + }; + + var enricher = new McpTelemetryEnricher( + tagger: null, + profiles: new FakeProfileStore(profile), + connections: new FakeConnectionStore(connection), + credentials: new FakeCredentialStore(credential), + globalConfig: new FakeGlobalConfigStore(new GlobalConfig()), + workspaceDiscovery: new FakeWorkspaceDiscovery("workspace-profile")); + var arguments = new Dictionary + { + ["profile"] = JsonDocument.Parse("\"custom-profile\"").RootElement.Clone() + }; + + await enricher.TagActivityAsync(activity, arguments, workingDirectory: null, CancellationToken.None); + + Assert.Equal("user@example.com", activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + Assert.Equal("tenant-123", activity?.GetTagItem(TxcTelemetryTags.EndUserScope)); + } + + [Fact] + public async Task TagActivityAsync_WithoutExplicitProfile_UsesProvidedWorkingDirectoryForWorkspaceResolution() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("execute_operation"); + + var profile = new Profile { Id = "workspace-profile", ConnectionRef = "conn", CredentialRef = "cred" }; + var connection = new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + DisplayName = "Contoso Sandbox", + TenantId = "tenant-123" + }; + var credential = new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + InteractiveUpn = "user@example.com" + }; + + var enricher = new McpTelemetryEnricher( + tagger: null, + profiles: new FakeProfileStore(profile), + connections: new FakeConnectionStore(connection), + credentials: new FakeCredentialStore(credential), + globalConfig: new FakeGlobalConfigStore(new GlobalConfig()), + workspaceDiscovery: new FakeWorkspaceDiscovery("workspace-profile")); + + await enricher.TagActivityAsync( + activity, + arguments: null, + workingDirectory: "/tmp/client-root", + CancellationToken.None); + + Assert.Equal("user@example.com", activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + } + + [Theory] + [InlineData("profile-a", "profile-a")] + [InlineData(null, null)] + public void TryGetProfile_ReadsProfileStringOrReturnsNull(string? jsonValue, string? expected) + { + IReadOnlyDictionary? arguments = jsonValue is null + ? null + : new Dictionary + { + ["profile"] = JsonDocument.Parse($"\"{jsonValue}\"").RootElement.Clone() + }; + + var result = McpTelemetryEnricher.TryGetProfile(arguments); + + Assert.Equal(expected, result); + } + + private static ResolvedProfileContext CreateResolvedProfileContext() + { + return new ResolvedProfileContext( + new Profile { Id = "test-profile", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + DisplayName = "Contoso Sandbox", + TenantId = "tenant-123" + }, + new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + InteractiveUpn = "user@example.com" + }, + ResolutionSource.CommandLine); + } + + private static ActivityListener CreateListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == TxcActivitySource.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + private sealed class FakeConfigurationResolver : IConfigurationResolver + { + private readonly Func _factory; + + public FakeConfigurationResolver(Func factory) + { + _factory = factory; + } + + public List RequestedProfiles { get; } = []; + + public Task ResolveAsync(string? profileName, CancellationToken ct) + { + RequestedProfiles.Add(profileName); + return Task.FromResult(_factory(profileName)); + } + } + + private sealed class FakeProfileStore : IProfileStore + { + private readonly Profile _profile; + + public FakeProfileStore(Profile profile) => _profile = profile; + + public Task> ListAsync(CancellationToken ct) => Task.FromResult>([_profile]); + + public Task GetAsync(string id, CancellationToken ct) + => Task.FromResult(string.Equals(id, _profile.Id, StringComparison.Ordinal) ? _profile : null); + + public Task UpsertAsync(Profile profile, CancellationToken ct) => throw new NotSupportedException(); + + public Task DeleteAsync(string id, CancellationToken ct) => throw new NotSupportedException(); + } + + private sealed class FakeConnectionStore : IConnectionStore + { + private readonly Connection _connection; + + public FakeConnectionStore(Connection connection) => _connection = connection; + + public Task> ListAsync(CancellationToken ct) => Task.FromResult>([_connection]); + + public Task GetAsync(string id, CancellationToken ct) + => Task.FromResult(string.Equals(id, _connection.Id, StringComparison.Ordinal) ? _connection : null); + + public Task UpsertAsync(Connection connection, CancellationToken ct) => throw new NotSupportedException(); + + public Task DeleteAsync(string id, CancellationToken ct) => throw new NotSupportedException(); + } + + private sealed class FakeCredentialStore : ICredentialStore + { + private readonly Credential _credential; + + public FakeCredentialStore(Credential credential) => _credential = credential; + + public Task> ListAsync(CancellationToken ct) => Task.FromResult>([_credential]); + + public Task GetAsync(string id, CancellationToken ct) + => Task.FromResult(string.Equals(id, _credential.Id, StringComparison.Ordinal) ? _credential : null); + + public Task UpsertAsync(Credential credential, CancellationToken ct) => throw new NotSupportedException(); + + public Task DeleteAsync(string id, CancellationToken ct) => throw new NotSupportedException(); + } + + private sealed class FakeGlobalConfigStore : IGlobalConfigStore + { + private readonly GlobalConfig _config; + + public FakeGlobalConfigStore(GlobalConfig config) => _config = config; + + public Task LoadAsync(CancellationToken ct) => Task.FromResult(_config); + + public Task SaveAsync(GlobalConfig config, CancellationToken ct) => throw new NotSupportedException(); + } + + private sealed class FakeWorkspaceDiscovery : IWorkspaceDiscovery + { + private readonly string _defaultProfile; + + public FakeWorkspaceDiscovery(string defaultProfile) + { + _defaultProfile = defaultProfile; + } + + public Task DiscoverAsync(string startDirectory, CancellationToken ct) + { + return Task.FromResult( + new WorkspaceResolution( + startDirectory, + Path.Combine(startDirectory, ".txc", "workspace.json"), + new WorkspaceConfig { DefaultProfile = _defaultProfile })); + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Telemetry/ActivityIdentityTaggerTests.cs b/tests/TALXIS.CLI.Tests/Telemetry/ActivityIdentityTaggerTests.cs new file mode 100644 index 0000000..39d202f --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Telemetry/ActivityIdentityTaggerTests.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using TALXIS.CLI.Abstractions; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Telemetry; +using Xunit; + +namespace TALXIS.CLI.Tests.Telemetry; + +public class ActivityIdentityTaggerTests +{ + [Fact] + public async Task TagFromProfileAsync_WithExplicitProfile_TagsActivity() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("workspace_validate"); + + var tagger = new ActivityIdentityTagger(new FakeConfigurationResolver(_ => CreateResolvedProfileContext())); + + await tagger.TagFromProfileAsync(activity, "explicit-profile", CancellationToken.None); + + Assert.Equal("11111111-1111-1111-1111-111111111111", activity?.GetTagItem(TxcTelemetryTags.EndUserId)); + Assert.Equal("user@example.com", activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + Assert.Equal("tenant-123", activity?.GetTagItem(TxcTelemetryTags.EndUserScope)); + Assert.Equal("https://contoso.crm.dynamics.com", activity?.GetTagItem(TxcTelemetryTags.EnvironmentUrl)); + Assert.Equal("Contoso Sandbox", activity?.GetTagItem(TxcTelemetryTags.EnvironmentName)); + } + + [Fact] + public async Task TagFromActiveProfileAsync_UsesResolverFallbackChain() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("workspace_validate"); + + var resolver = new FakeConfigurationResolver(_ => CreateResolvedProfileContext()); + var tagger = new ActivityIdentityTagger(resolver); + + await tagger.TagFromActiveProfileAsync(activity); + + Assert.Single(resolver.RequestedProfiles); + Assert.Null(resolver.RequestedProfiles[0]); + Assert.Equal("user@example.com", activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + } + + [Fact] + public async Task TagFromProfileAsync_OnResolutionFailure_LeavesActivityUntouched() + { + using var listener = CreateListener(); + using var activity = TxcActivitySource.Instance.StartActivity("workspace_validate"); + + var tagger = new ActivityIdentityTagger( + new FakeConfigurationResolver(_ => throw new ConfigurationResolutionException("missing profile"))); + + await tagger.TagFromProfileAsync(activity, "missing-profile", CancellationToken.None); + + Assert.Null(activity?.GetTagItem(TxcTelemetryTags.EndUserId)); + Assert.Null(activity?.GetTagItem(TxcTelemetryTags.EndUserName)); + Assert.Null(activity?.GetTagItem(TxcTelemetryTags.EndUserScope)); + } + + private static ResolvedProfileContext CreateResolvedProfileContext() + { + return new ResolvedProfileContext( + new Profile { Id = "test-profile", ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentUrl = "https://contoso.crm.dynamics.com", + DisplayName = "Contoso Sandbox", + TenantId = "tenant-123" + }, + new Credential + { + Id = "cred", + Kind = CredentialKind.InteractiveBrowser, + InteractiveAccountId = "11111111-1111-1111-1111-111111111111.tenant-123", + InteractiveUpn = "user@example.com" + }, + ResolutionSource.CommandLine); + } + + private static ActivityListener CreateListener() + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == TxcActivitySource.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + private sealed class FakeConfigurationResolver : IConfigurationResolver + { + private readonly Func _factory; + + public FakeConfigurationResolver(Func factory) + { + _factory = factory; + } + + public List RequestedProfiles { get; } = []; + + public Task ResolveAsync(string? profileName, CancellationToken ct) + { + RequestedProfiles.Add(profileName); + return Task.FromResult(_factory(profileName)); + } + } +}