Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/TALXIS.CLI.Core/Shared/TxcLeafCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ private static async Task TagActiveProfileIdentityAsync(Activity? activity)
{
var tagger = DependencyInjection.TxcServices.GetOptional<Telemetry.ActivityIdentityTagger>();
if (tagger != null)
await tagger.TagFromActiveProfileAsync(activity).ConfigureAwait(false);
await tagger.TagFromProfileAsync(activity, profileName: null, CancellationToken.None).ConfigureAwait(false);
}

/// <summary>
Expand Down
21 changes: 12 additions & 9 deletions src/TALXIS.CLI.Core/Telemetry/ActivityIdentityTagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,26 @@ namespace TALXIS.CLI.Core.Telemetry;
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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)
Expand All @@ -47,6 +44,12 @@ public async Task TagFromActiveProfileAsync(Activity? activity)
}
}

/// <summary>
/// Tags the Activity with identity from the ambient resolver fallback chain.
/// </summary>
public Task TagFromActiveProfileAsync(Activity? activity)
=> TagFromProfileAsync(activity, profileName: null, CancellationToken.None);

/// <summary>
/// Tags the Activity with identity from an already-resolved profile.
/// Called by <see cref="ProfiledCliCommand"/> when <c>--profile</c> is specified explicitly.
Expand Down
52 changes: 45 additions & 7 deletions src/TALXIS.CLI.Logging/TxcTelemetryLogProvider.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using TALXIS.CLI.Abstractions;

namespace TALXIS.CLI.Logging;

Expand Down Expand Up @@ -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>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && Activity.Current != null;
Expand All @@ -51,13 +62,19 @@ public void Log<TState>(
// 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.,
Expand All @@ -70,11 +87,32 @@ public void Log<TState>(
{
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);
}
}
145 changes: 145 additions & 0 deletions src/TALXIS.CLI.MCP/McpTelemetryEnricher.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? arguments,
string? workingDirectory,
CancellationToken ct)
{
if (activity == null)
return Task.CompletedTask;

return TagActivityCoreAsync(activity, arguments, workingDirectory, ct);
}

internal static string? TryGetProfile(IReadOnlyDictionary<string, JsonElement>? 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<string, JsonElement>? 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;
}
Comment on lines +71 to +86

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;
}
}
Loading