diff --git a/README.md b/README.md index c79092cd..e147e187 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ The environment layer is organised into three planes: | **Application** | Solutions, packages, deployments, schema management | `txc env sln …`, `txc env pkg …`, `txc env deploy …`, `txc env entity …` | | **Data** | Records, queries, bulk operations, CMT import/export | `txc env data …`, `txc data …` | +Read-only **diagnostics** cut across all three — read runtime logs straight from the terminal with `txc env log …` (plug-in traces, async jobs, workflow runs). See [Diagnostics — Environment Logs](#diagnostics--environment-logs). + ### Workspace — Local-First Development The fastest way to build Dataverse components. Everything happens locally in your repo — no environment round-trips, no publish waits. Ideal for coding agents that need to scaffold dozens of components in a session. @@ -171,6 +173,38 @@ txc env component layer list --entity account --attribute revenue txc env component dep delete-check --entity tom_project ``` +### Diagnostics — Environment Logs + +Read runtime logs straight from the terminal instead of dropping into raw SQL or the maker portal. + +```sh +# Unified recent feed across plug-in traces and async jobs +txc env log list --since 24h + +# Plug-in execution traces (needs plug-in trace logging enabled in the environment) +txc env log plugin-trace --since 1h --errors-only +txc env log plugin-trace --plugin Acme.Plugins.Account --entity account + +# Background system jobs and classic workflow runs +txc env log async-jobs --errors-only +txc env log workflow --since 7d + +# Live tail — keep watching new async jobs as they appear (Ctrl+C to stop) +txc env log async-jobs --follow +txc env log async-jobs --follow --errors-only --interval 10 + +# Follow one operation across sources by its correlation id +txc env log list --correlation-id 5f9c2e7a-1234-4abc-9def-0123456789ab +``` + +Shared flags: `--since` (`30m`/`24h`/`7d`/`2w`), `--entity`, `--errors-only`, `--correlation-id`, `--top` +(`--plugin` applies to `plugin-trace` and `list`). Add `--format json` for machine-readable output. + +> [!NOTE] +> `plugin-trace` is empty unless plug-in trace logging is enabled in the environment +> (System Settings → Customization → plug-in trace log). Async-job rows are purged by Dataverse +> retention after a few days, so very old runs may no longer be present. + ### Data Plane Query, create, update, and bulk-operate on Dataverse records. diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IEnvironmentLogService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IEnvironmentLogService.cs new file mode 100644 index 00000000..07e653d8 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IEnvironmentLogService.cs @@ -0,0 +1,111 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// Parsed, source-agnostic filter applied to environment-log reads. Built once +/// by a leaf command from its CLI flags and passed straight to the reader so the +/// service signatures stay small. +/// +/// Lower bound on the row timestamp (createdon). null = no bound. +/// Logical name of an entity to filter by (plug-in primaryentity / async regarding object). null = all. +/// Plug-in / activity type-name substring to filter by (plug-in traces only). null = all. +/// When true, keep only error rows (traces with an exception, failed/cancelled jobs). +/// Restrict to a single operation's correlation id. null = all. +/// Maximum number of rows to return. +public sealed record EnvironmentLogFilter( + DateTime? SinceUtc, + string? Entity, + string? Plugin, + bool ErrorsOnly, + Guid? CorrelationId, + int Top); + +/// +/// Row from the Dataverse plugintracelog table. All +/// values are UTC. +/// +public sealed record PluginTraceRecord( + Guid Id, + DateTime? CreatedOnUtc, + string? TypeName, + string? MessageName, + string? PrimaryEntity, + string? Mode, + int? Depth, + long? DurationMs, + bool HasException, + string? ExceptionSnippet, + string? MessageSnippet, + Guid? CorrelationId); + +/// +/// Row from the Dataverse asyncoperation table (background jobs, including +/// classic workflow runs). All values are UTC. +/// +public sealed record AsyncJobRecord( + Guid Id, + string? Name, + int? OperationTypeCode, + string? OperationTypeLabel, + string? StatusLabel, + bool IsError, + DateTime? CreatedOnUtc, + DateTime? StartedOnUtc, + DateTime? CompletedOnUtc, + string? RegardingEntity, + string? Message, + Guid? CorrelationId); + +/// +/// Reads runtime diagnostic logs from a live environment — plug-in traces and +/// async jobs (background workflows included). Hides the underlying readers and +/// connection lifetime from feature commands. +/// +public interface IEnvironmentLogService +{ + /// + /// Reads recent plugintracelog rows ordered by createdon descending, + /// honouring . + /// + Task> GetPluginTracesAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct); + + /// + /// Reads recent asyncoperation rows of any operation type, ordered by + /// createdon descending, honouring . + /// + Task> GetAsyncJobsAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct); + + /// + /// Reads recent classic (background) workflow runs — asyncoperation rows + /// whose operation type is Workflow — ordered by createdon descending, + /// honouring . + /// + Task> GetWorkflowRunsAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct); + + /// + /// Resolves the profile and opens the environment connection once, returning a + /// reader bound to that connection. Use this for a --follow poll loop so + /// each tick doesn't re-resolve the profile and re-open a connection. + /// + Task CreateAsyncJobReaderAsync( + string? profileName, + int? operationTypeFilter, + CancellationToken ct); +} + +/// +/// An async-job reader bound to an already-open environment connection. Dispose +/// to close the connection. +/// +public interface IAsyncJobLogReader : IDisposable +{ + Task> ReadAsync(EnvironmentLogFilter filter, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/EnvLogFilterBuilder.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/EnvLogFilterBuilder.cs new file mode 100644 index 00000000..5f069cbb --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/EnvLogFilterBuilder.cs @@ -0,0 +1,64 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Parses the shared environment-log CLI flags into an . +/// Centralizes --since and --correlation-id validation so every leaf command +/// behaves identically. Returns false with a user-facing +/// message on malformed input. +/// +internal static class EnvLogFilterBuilder +{ + private const int DefaultTop = 50; + private const int DefaultTopWithSince = 200; + + public static bool TryBuild( + string? since, + string? entity, + string? plugin, + bool errorsOnly, + string? correlationId, + int? top, + out EnvironmentLogFilter filter, + out string? error) + { + filter = null!; + error = null; + + DateTime? sinceUtc = null; + if (!string.IsNullOrWhiteSpace(since)) + { + if (!DeploymentRelativeTimeParser.TryParse(since, out var window)) + { + error = $"Invalid --since value '{since}'. Use NNNm, NNNh, NNNd, or NNNw."; + return false; + } + sinceUtc = DateTime.UtcNow - window; + } + + Guid? correlation = null; + if (!string.IsNullOrWhiteSpace(correlationId)) + { + if (!Guid.TryParse(correlationId.Trim(), out var parsed)) + { + error = $"Invalid --correlation-id value '{correlationId}'. Expected a GUID."; + return false; + } + correlation = parsed; + } + + int effectiveTop = top is > 0 + ? top.Value + : (sinceUtc is null ? DefaultTop : DefaultTopWithSince); + + filter = new EnvironmentLogFilter( + SinceUtc: sinceUtc, + Entity: string.IsNullOrWhiteSpace(entity) ? null : entity.Trim(), + Plugin: string.IsNullOrWhiteSpace(plugin) ? null : plugin.Trim(), + ErrorsOnly: errorsOnly, + CorrelationId: correlation, + Top: effectiveTop); + return true; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogAsyncJobsCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogAsyncJobsCliCommand.cs new file mode 100644 index 00000000..b2c21e0e --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogAsyncJobsCliCommand.cs @@ -0,0 +1,192 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Reads background system jobs from the asyncoperation table. +/// +/// +/// txc environment log async-jobs --errors-only --since 24h +/// txc env log async-jobs --entity account --format json +/// +[CliReadOnly] +[CliCommand( + Name = "async-jobs", + Description = "Read background system jobs (asyncoperation) from the LIVE environment. Requires an active profile." +)] +public class LogAsyncJobsCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LogAsyncJobsCliCommand)); + + [CliOption(Name = "--since", Description = "Relative time window, e.g. 30m, 24h, 7d, 2w.", Required = false)] + public string? Since { get; set; } + + [CliOption(Name = "--entity", Description = "Filter by the job's regarding-object entity logical name (best-effort, client-side).", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--errors-only", Description = "Only failed or cancelled jobs.", Required = false)] + public bool ErrorsOnly { get; set; } + + [CliOption(Name = "--correlation-id", Description = "Restrict to a single operation's correlation id (GUID).", Required = false)] + public string? CorrelationId { get; set; } + + [CliOption(Name = "--top", Description = "Maximum number of jobs to return.", Required = false)] + public int? Top { get; set; } + + [CliOption(Name = "--follow", Description = "Live tail: keep polling and print new jobs as they appear (interactive only).", Required = false)] + public bool Follow { get; set; } + + [CliOption(Name = "--interval", Description = "Polling interval in seconds for --follow (default 5, min 2).", Required = false)] + public string? Interval { get; set; } + + protected override async Task ExecuteAsync() + { + if (!EnvLogFilterBuilder.TryBuild(Since, Entity, plugin: null, ErrorsOnly, CorrelationId, Top, out var filter, out var error)) + { + Logger.LogError("{Error}", error); + return ExitValidationError; + } + + if (Follow) + return await RunFollowAsync(filter).ConfigureAwait(false); + + var service = TxcServices.Get(); + var rows = await service.GetAsyncJobsAsync(Profile, filter, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, r => PrintTable(r, "No async jobs found.")); + return ExitSuccess; + } + + private async Task RunFollowAsync(EnvironmentLogFilter filter) + { + if (!FollowSupport.TryParseInterval(Interval, out var interval, out var intervalError)) + { + Logger.LogError("{Error}", intervalError); + return ExitValidationError; + } + + var detector = TxcServices.Get(); + var isMcp = string.Equals( + System.Environment.GetEnvironmentVariable("TXC_ENTRY_POINT"), "mcp", StringComparison.OrdinalIgnoreCase); + if (detector.IsHeadless || isMcp) + { + Logger.LogError( + "--follow is interactive-only and is not supported in non-interactive mode ({Reason}). Run a one-shot query instead.", + isMcp ? "mcp" : detector.Reason); + return ExitValidationError; + } + + using var cts = new CancellationTokenSource(); + ConsoleCancelEventHandler onCancel = (_, e) => { e.Cancel = true; cts.Cancel(); }; + System.Console.CancelKeyPress += onCancel; + + try + { + var service = TxcServices.Get(); + // Resolve + connect ONCE; the poll loop reuses the open connection (no per-tick log spam). + using var reader = await service.CreateAsyncJobReaderAsync(Profile, operationTypeFilter: null, cts.Token) + .ConfigureAwait(false); + + Logger.LogInformation( + "Watching async jobs every {Seconds}s. Press Ctrl+C to stop.", (int)interval.TotalSeconds); + + var tracker = new FollowTracker(j => j.Id.ToString()); + var headerPrinted = false; + + while (!cts.IsCancellationRequested) + { + IReadOnlyList jobs; + try + { + jobs = await reader.ReadAsync(filter, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + foreach (var job in tracker.SelectNew(jobs)) + EmitFollowRow(job, ref headerPrinted); + + try + { + await Task.Delay(interval, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + catch (OperationCanceledException) + { + // cancelled during initial connect — clean exit + } + finally + { + System.Console.CancelKeyPress -= onCancel; + } + + return ExitSuccess; + } + + /// + /// Shared async-job table renderer, reused by . + /// + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + internal static void PrintTable(IReadOnlyList rows, string emptyMessage) + { + if (rows.Count == 0) + { + OutputWriter.WriteLine(emptyMessage); + return; + } + + int nameWidth = Math.Clamp(rows.Max(r => (r.Name ?? "").Length), 16, 44); + int typeWidth = Math.Clamp(rows.Max(r => (r.OperationTypeLabel ?? "").Length), 8, 24); + int statusWidth = Math.Clamp(rows.Max(r => (r.StatusLabel ?? "").Length), 8, 18); + + string header = $"{"Created (UTC)",-19} | {"Lvl",-3} | {"Name".PadRight(nameWidth)} | {"Type".PadRight(typeWidth)} | {"Status".PadRight(statusWidth)} | Message"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + string created = r.CreatedOnUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "(unknown)"; + string level = r.IsError ? "ERR" : "ok"; + OutputWriter.WriteLine( + $"{created,-19} | {level,-3} | {LogText.Fit(r.Name, nameWidth)} | {LogText.Fit(r.OperationTypeLabel, typeWidth)} | {LogText.Fit(r.StatusLabel, statusWidth)} | {LogText.Truncate(r.Message, 80)}"); + } + OutputWriter.WriteLine($"({rows.Count} job{(rows.Count == 1 ? "" : "s")})"); + } + + // Streaming row for --follow: fixed column widths (no batch to size from). Header once. + private const int FollowNameWidth = 40; + private const int FollowTypeWidth = 20; + private const int FollowStatusWidth = 14; + + private static string FollowHeader() => + $"{"Created (UTC)",-19} | {"Lvl",-3} | {"Name".PadRight(FollowNameWidth)} | {"Type".PadRight(FollowTypeWidth)} | {"Status".PadRight(FollowStatusWidth)} | Message"; + + private static void EmitFollowRow(AsyncJobRecord r, ref bool headerPrinted) + { + if (!headerPrinted) + { + OutputWriter.WriteLine(FollowHeader()); + OutputWriter.WriteLine(new string('-', FollowHeader().Length)); + headerPrinted = true; + } + + string created = r.CreatedOnUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "(unknown)"; + string level = r.IsError ? "ERR" : "ok"; + OutputWriter.WriteLine( + $"{created,-19} | {level,-3} | {LogText.Fit(r.Name, FollowNameWidth)} | {LogText.Fit(r.OperationTypeLabel, FollowTypeWidth)} | {LogText.Fit(r.StatusLabel, FollowStatusWidth)} | {LogText.Truncate(r.Message, 80)}"); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogCliCommand.cs new file mode 100644 index 00000000..76729cdc --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogCliCommand.cs @@ -0,0 +1,27 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Parent command grouping runtime diagnostic log readers for a live environment +/// — plug-in traces, async jobs, classic workflow runs, and a unified feed. +/// +[CliCommand( + Name = "log", + Description = "Read runtime diagnostic logs from a live environment (plug-in traces, async jobs, workflow runs).", + Children = new[] + { + typeof(LogListCliCommand), + typeof(LogPluginTraceCliCommand), + typeof(LogAsyncJobsCliCommand), + typeof(LogWorkflowCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class LogCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFollow.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFollow.cs new file mode 100644 index 00000000..d8bc6c0a --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFollow.cs @@ -0,0 +1,69 @@ +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Helpers for the --follow live-tail loop. +/// +internal static class FollowSupport +{ + private static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(2); + + /// + /// Parses --interval (whole seconds). Empty/null → default 5s. + /// Rejects non-integer input and intervals below the 2s floor. + /// + public static bool TryParseInterval(string? value, out TimeSpan interval, out string? error) + { + interval = DefaultInterval; + error = null; + + if (string.IsNullOrWhiteSpace(value)) + return true; + + if (!int.TryParse(value.Trim(), out var seconds)) + { + error = $"Invalid --interval value '{value}'. Expected whole seconds (e.g. 5)."; + return false; + } + + if (seconds < MinInterval.TotalSeconds) + { + error = $"--interval must be at least {(int)MinInterval.TotalSeconds} seconds."; + return false; + } + + interval = TimeSpan.FromSeconds(seconds); + return true; + } +} + +/// +/// Stateful dedup for a live tail: tracks which rows have already been printed +/// (by a string key) and returns only the unseen ones, in chronological +/// (oldest-first) order suitable for streaming. +/// +internal sealed class FollowTracker +{ + private readonly HashSet _seen = new(StringComparer.Ordinal); + private readonly Func _key; + + public FollowTracker(Func key) => _key = key ?? throw new ArgumentNullException(nameof(key)); + + /// + /// Given a freshly fetched batch (newest-first, as the readers return it), + /// returns the rows not seen before, ordered oldest-first, and marks them seen. + /// + public IReadOnlyList SelectNew(IEnumerable fetched) + { + var fresh = new List(); + foreach (var item in fetched) + { + var key = _key(item); + if (!string.IsNullOrEmpty(key) && _seen.Add(key)) + fresh.Add(item); + } + // Input is newest-first; reverse so the tail prints oldest-first. + fresh.Reverse(); + return fresh; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogListCliCommand.cs new file mode 100644 index 00000000..aac8c955 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogListCliCommand.cs @@ -0,0 +1,151 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Unified recent-log feed that merges plug-in traces and async jobs into a single +/// time-ordered stream. +/// +/// +/// txc environment log list --since 24h +/// txc env log list --errors-only --format json +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "Unified recent log feed across sources (plug-in traces + async jobs) from the LIVE environment." +)] +public class LogListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LogListCliCommand)); + + [CliOption(Name = "--since", Description = "Relative time window, e.g. 30m, 24h, 7d, 2w.", Required = false)] + public string? Since { get; set; } + + [CliOption(Name = "--entity", Description = "Filter by entity logical name (plug-in primary entity / async regarding object).", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--plugin", Description = "Filter plug-in traces by type-name (substring match).", Required = false)] + public string? Plugin { get; set; } + + [CliOption(Name = "--errors-only", Description = "Only error rows (traces with an exception, failed/cancelled jobs).", Required = false)] + public bool ErrorsOnly { get; set; } + + [CliOption(Name = "--correlation-id", Description = "Restrict to a single operation's correlation id (GUID).", Required = false)] + public string? CorrelationId { get; set; } + + [CliOption(Name = "--top", Description = "Maximum number of rows to fetch per source before merging.", Required = false)] + public int? Top { get; set; } + + protected override async Task ExecuteAsync() + { + if (!EnvLogFilterBuilder.TryBuild(Since, Entity, Plugin, ErrorsOnly, CorrelationId, Top, out var filter, out var error)) + { + Logger.LogError("{Error}", error); + return ExitValidationError; + } + + var service = TxcServices.Get(); + var tracesTask = service.GetPluginTracesAsync(Profile, filter, CancellationToken.None); + var jobsTask = service.GetAsyncJobsAsync(Profile, filter, CancellationToken.None); + await Task.WhenAll(tracesTask, jobsTask).ConfigureAwait(false); + + var rows = BuildRows(await tracesTask.ConfigureAwait(false), await jobsTask.ConfigureAwait(false), filter.ErrorsOnly); + + // Without an explicit window, show a compact recent slice; with --since, show everything in range. + int max = filter.SinceUtc is null ? 20 : rows.Count; + var trimmed = rows.Take(max).ToList(); + + OutputFormatter.WriteList(trimmed, PrintTable); + return ExitSuccess; + } + + /// + /// Normalizes plug-in traces and async jobs into a single feed sorted by + /// timestamp descending. When is set, keeps only + /// rows whose level is ERROR. + /// + public static IReadOnlyList BuildRows( + IReadOnlyList traces, + IReadOnlyList jobs, + bool errorsOnly) + { + var rows = new List(traces.Count + jobs.Count); + + foreach (var t in traces) + { + rows.Add(new EnvLogRow( + Source: "plugin", + TimestampUtc: t.CreatedOnUtc, + Level: t.HasException ? "ERROR" : "OK", + Name: t.TypeName ?? "(unknown)", + Entity: t.PrimaryEntity, + CorrelationId: t.CorrelationId, + Message: t.HasException ? t.ExceptionSnippet : t.MessageSnippet)); + } + + foreach (var j in jobs) + { + rows.Add(new EnvLogRow( + Source: "async", + TimestampUtc: j.CreatedOnUtc, + Level: j.IsError ? "ERROR" : "OK", + Name: j.Name ?? j.OperationTypeLabel ?? "(unknown)", + Entity: j.RegardingEntity, + CorrelationId: j.CorrelationId, + Message: j.Message)); + } + + IEnumerable result = rows; + if (errorsOnly) + result = result.Where(r => r.Level == "ERROR"); + + return result + .OrderByDescending(r => r.TimestampUtc ?? DateTime.MinValue) + .ToList(); + } + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) + { + OutputWriter.WriteLine("No log entries found."); + return; + } + + int nameWidth = Math.Clamp(rows.Max(r => r.Name.Length), 16, 44); + int entityWidth = Math.Clamp(rows.Max(r => (r.Entity ?? "").Length), 6, 24); + + string header = $"{"Created (UTC)",-19} | {"Src",-6} | {"Lvl",-5} | {"Name".PadRight(nameWidth)} | {"Entity".PadRight(entityWidth)} | Message"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + string created = r.TimestampUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "(unknown)"; + OutputWriter.WriteLine( + $"{created,-19} | {r.Source,-6} | {r.Level,-5} | {LogText.Fit(r.Name, nameWidth)} | {LogText.Fit(r.Entity, entityWidth)} | {LogText.Truncate(r.Message, 70)}"); + } + OutputWriter.WriteLine($"({rows.Count} entr{(rows.Count == 1 ? "y" : "ies")})"); + } +#pragma warning restore TXC003 +} + +/// +/// Unified row shape emitted by txc environment log list. Same contract in +/// text and JSON. +/// +public sealed record EnvLogRow( + string Source, + DateTime? TimestampUtc, + string Level, + string Name, + string? Entity, + Guid? CorrelationId, + string? Message); diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogPluginTraceCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogPluginTraceCliCommand.cs new file mode 100644 index 00000000..339d0029 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogPluginTraceCliCommand.cs @@ -0,0 +1,89 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Reads plug-in execution traces from the plugintracelog table. +/// +/// +/// txc environment log plugin-trace --since 24h --errors-only +/// txc env log plugin-trace --plugin MyCompany.Plugins.Account --entity account +/// +[CliReadOnly] +[CliCommand( + Name = "plugin-trace", + Description = "Read plug-in execution traces from the LIVE environment. Requires plug-in trace logging to be enabled in the environment." +)] +public class LogPluginTraceCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LogPluginTraceCliCommand)); + + [CliOption(Name = "--since", Description = "Relative time window, e.g. 30m, 24h, 7d, 2w.", Required = false)] + public string? Since { get; set; } + + [CliOption(Name = "--entity", Description = "Filter by primary entity logical name (e.g. account).", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--plugin", Description = "Filter by plug-in / activity type-name (substring match).", Required = false)] + public string? Plugin { get; set; } + + [CliOption(Name = "--errors-only", Description = "Only traces that recorded an exception.", Required = false)] + public bool ErrorsOnly { get; set; } + + [CliOption(Name = "--correlation-id", Description = "Restrict to a single operation's correlation id (GUID).", Required = false)] + public string? CorrelationId { get; set; } + + [CliOption(Name = "--top", Description = "Maximum number of traces to return.", Required = false)] + public int? Top { get; set; } + + protected override async Task ExecuteAsync() + { + if (!EnvLogFilterBuilder.TryBuild(Since, Entity, Plugin, ErrorsOnly, CorrelationId, Top, out var filter, out var error)) + { + Logger.LogError("{Error}", error); + return ExitValidationError; + } + + var service = TxcServices.Get(); + var rows = await service.GetPluginTracesAsync(Profile, filter, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, PrintTable); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) + { + OutputWriter.WriteLine("No plug-in traces found. (Is plug-in trace logging enabled in the environment?)"); + return; + } + + int typeWidth = Math.Clamp(rows.Max(r => (r.TypeName ?? "").Length), 16, 44); + int msgWidth = Math.Max(7, rows.Max(r => (r.MessageName ?? "").Length)); + int entityWidth = Math.Clamp(rows.Max(r => (r.PrimaryEntity ?? "").Length), 6, 24); + + string header = $"{"Created (UTC)",-19} | {"Lvl",-3} | {"Type".PadRight(typeWidth)} | {"Message".PadRight(msgWidth)} | {"Entity".PadRight(entityWidth)} | Detail"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var r in rows) + { + string created = r.CreatedOnUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "(unknown)"; + string level = r.HasException ? "ERR" : "ok"; + string type = LogText.Fit(r.TypeName, typeWidth); + string msg = (r.MessageName ?? "").PadRight(msgWidth); + string entity = LogText.Fit(r.PrimaryEntity, entityWidth); + string detail = r.HasException ? (r.ExceptionSnippet ?? "") : (r.MessageSnippet ?? ""); + OutputWriter.WriteLine($"{created,-19} | {level,-3} | {type} | {msg} | {entity} | {detail}"); + } + OutputWriter.WriteLine($"({rows.Count} trace{(rows.Count == 1 ? "" : "s")})"); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogText.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogText.cs new file mode 100644 index 00000000..2118eb40 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogText.cs @@ -0,0 +1,30 @@ +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Shared text helpers for the environment-log table renderers. Keeps cell +/// padding and message trimming in one place instead of being copy-pasted into +/// each leaf command's text renderer. +/// +internal static class LogText +{ + /// + /// Fits to exactly columns: + /// pads short values, and ellipsis-truncates long ones. + /// + public static string Fit(string? value, int width) + { + var v = value ?? string.Empty; + return v.Length > width ? v[..(width - 1)] + "…" : v.PadRight(width); + } + + /// + /// Collapses a (possibly multi-line) value to a single trimmed line capped at + /// characters. Returns an empty string for null/blank input. + /// + public static string Truncate(string? value, int max) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var oneLine = value.Replace("\r", " ").Replace("\n", " ").Trim(); + return oneLine.Length > max ? oneLine[..(max - 1)] + "…" : oneLine; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogWorkflowCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogWorkflowCliCommand.cs new file mode 100644 index 00000000..dc255d9e --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogWorkflowCliCommand.cs @@ -0,0 +1,56 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Diagnostics; + +/// +/// Reads classic (background) workflow run history. These runs are +/// asyncoperation rows whose operation type is Workflow (10). +/// +/// +/// txc environment log workflow --since 7d +/// txc env log workflow --errors-only --entity account +/// +[CliReadOnly] +[CliCommand( + Name = "workflow", + Description = "Read classic background workflow run history from the LIVE environment. Requires an active profile." +)] +public class LogWorkflowCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LogWorkflowCliCommand)); + + [CliOption(Name = "--since", Description = "Relative time window, e.g. 30m, 24h, 7d, 2w.", Required = false)] + public string? Since { get; set; } + + [CliOption(Name = "--entity", Description = "Filter by the workflow's regarding-object entity logical name (best-effort, client-side).", Required = false)] + public string? Entity { get; set; } + + [CliOption(Name = "--errors-only", Description = "Only failed or cancelled workflow runs.", Required = false)] + public bool ErrorsOnly { get; set; } + + [CliOption(Name = "--correlation-id", Description = "Restrict to a single operation's correlation id (GUID).", Required = false)] + public string? CorrelationId { get; set; } + + [CliOption(Name = "--top", Description = "Maximum number of runs to return.", Required = false)] + public int? Top { get; set; } + + protected override async Task ExecuteAsync() + { + if (!EnvLogFilterBuilder.TryBuild(Since, Entity, plugin: null, ErrorsOnly, CorrelationId, Top, out var filter, out var error)) + { + Logger.LogError("{Error}", error); + return ExitValidationError; + } + + var service = TxcServices.Get(); + var rows = await service.GetWorkflowRunsAsync(Profile, filter, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(rows, r => LogAsyncJobsCliCommand.PrintTable(r, "No workflow runs found.")); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 7b3fd0ba..e81ad280 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(Diagnostics.LogCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs index 5731793c..2d58f066 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddTxcDataverseApplication(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Domain/DataverseSchema.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Domain/DataverseSchema.cs index 5f58ed24..bbc4f938 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Domain/DataverseSchema.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Domain/DataverseSchema.cs @@ -14,6 +14,50 @@ public static class Solution public static class AsyncOperation { public const string EntityName = "asyncoperation"; + public const string AsyncOperationId = "asyncoperationid"; + public const string Name = "name"; + public const string OperationType = "operationtype"; + public const string StateCode = "statecode"; + public const string StatusCode = "statuscode"; + public const string Message = "message"; + public const string FriendlyMessage = "friendlymessage"; + public const string RegardingObjectId = "regardingobjectid"; + public const string CreatedOn = "createdon"; + public const string StartedOn = "startedon"; + public const string CompletedOn = "completedon"; + public const string CorrelationId = "correlationid"; + public const string ErrorCode = "errorcode"; + + /// Option-set value of operationtype for a classic workflow run. + public const int OperationTypeWorkflow = 10; + + /// statuscode value for a failed async job. + public const int StatusCodeFailed = 31; + + /// statuscode value for a cancelled async job. + public const int StatusCodeCanceled = 32; + } + + /// + /// Standard table plugintracelog — plug-in / custom-workflow-activity + /// execution traces. Populated only when plug-in trace logging is enabled in + /// the environment (System Settings → Customization → plug-in trace log). + /// + public static class PluginTraceLog + { + public const string EntityName = "plugintracelog"; + public const string PluginTraceLogId = "plugintracelogid"; + public const string CreatedOn = "createdon"; + public const string TypeName = "typename"; + public const string MessageName = "messagename"; + public const string PrimaryEntity = "primaryentity"; + public const string Mode = "mode"; + public const string Depth = "depth"; + public const string OperationType = "operationtype"; + public const string ExceptionDetails = "exceptiondetails"; + public const string MessageBlock = "messageblock"; + public const string CorrelationId = "correlationid"; + public const string PerformanceExecutionDuration = "performanceexecutionduration"; } public static class ImportJob diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/AsyncOperationReader.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/AsyncOperationReader.cs new file mode 100644 index 00000000..c975d964 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/AsyncOperationReader.cs @@ -0,0 +1,162 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +/// +/// Reader for the standard asyncoperation table (background system jobs, +/// including classic workflow runs). Time, status, correlation and operation-type +/// filters are pushed server-side; the entity filter is client-side because +/// regardingobjectid is a polymorphic lookup with no cleanly filterable +/// entity-name column. +/// +public sealed class AsyncOperationReader +{ + private const string EntityName = DataverseSchema.AsyncOperation.EntityName; + private static readonly ColumnSet Columns = new( + DataverseSchema.AsyncOperation.AsyncOperationId, + DataverseSchema.AsyncOperation.Name, + DataverseSchema.AsyncOperation.OperationType, + DataverseSchema.AsyncOperation.StatusCode, + DataverseSchema.AsyncOperation.Message, + DataverseSchema.AsyncOperation.FriendlyMessage, + DataverseSchema.AsyncOperation.RegardingObjectId, + DataverseSchema.AsyncOperation.CreatedOn, + DataverseSchema.AsyncOperation.StartedOn, + DataverseSchema.AsyncOperation.CompletedOn, + DataverseSchema.AsyncOperation.CorrelationId, + DataverseSchema.AsyncOperation.ErrorCode); + + private readonly IOrganizationServiceAsync2 _service; + private readonly ILogger? _logger; + + public AsyncOperationReader(IOrganizationServiceAsync2 service, ILogger? logger = null) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _logger = logger; + } + + /// + /// Returns recent async-operation rows ordered by createdon descending, + /// applying (and the optional + /// ) server-side where possible. + /// + public async Task> GetRecentAsync( + EnvironmentLogFilter filter, + int? operationTypeFilter = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(filter); + + int top = filter.Top > 0 ? filter.Top : 50; + bool clientEntityFilter = !string.IsNullOrWhiteSpace(filter.Entity); + + var q = new QueryExpression(EntityName) + { + ColumnSet = Columns, + Criteria = new FilterExpression(LogicalOperator.And), + // Over-fetch when the entity filter runs client-side so we still + // return a full page after narrowing. + TopCount = clientEntityFilter ? Math.Max(top * 4, 50) : top, + }; + q.AddOrder(DataverseSchema.AsyncOperation.CreatedOn, OrderType.Descending); + + if (filter.SinceUtc is { } since) + { + q.Criteria.AddCondition( + DataverseSchema.AsyncOperation.CreatedOn, + ConditionOperator.GreaterEqual, + DataverseDateTime.EnsureUtc(since)); + } + + if (operationTypeFilter is { } opType) + { + q.Criteria.AddCondition( + DataverseSchema.AsyncOperation.OperationType, + ConditionOperator.Equal, + opType); + } + + if (filter.ErrorsOnly) + { + q.Criteria.AddCondition( + DataverseSchema.AsyncOperation.StatusCode, + ConditionOperator.In, + DataverseSchema.AsyncOperation.StatusCodeFailed, + DataverseSchema.AsyncOperation.StatusCodeCanceled); + } + + if (filter.CorrelationId is { } correlationId) + { + q.Criteria.AddCondition( + DataverseSchema.AsyncOperation.CorrelationId, + ConditionOperator.Equal, + correlationId); + } + + var res = await _service.RetrieveMultipleAsync(q, ct).ConfigureAwait(false); + IEnumerable records = res.Entities.Select(ToRecord); + + if (clientEntityFilter) + { + var entity = filter.Entity!.Trim(); + records = records + .Where(r => string.Equals(r.RegardingEntity, entity, StringComparison.OrdinalIgnoreCase)) + .Take(top); + } + + return records.ToList(); + } + + internal static AsyncJobRecord ToRecord(Entity e) + { + int? operationType = e.GetAttributeValue(DataverseSchema.AsyncOperation.OperationType)?.Value; + string? operationTypeLabel = + e.FormattedValues.TryGetValue(DataverseSchema.AsyncOperation.OperationType, out var opFmt) + && !string.IsNullOrWhiteSpace(opFmt) + ? opFmt + : operationType?.ToString(); + + int? statusCode = e.GetAttributeValue(DataverseSchema.AsyncOperation.StatusCode)?.Value; + string? statusLabel = + e.FormattedValues.TryGetValue(DataverseSchema.AsyncOperation.StatusCode, out var statusFmt) + && !string.IsNullOrWhiteSpace(statusFmt) + ? statusFmt + : statusCode?.ToString(); + + bool isError = statusCode is DataverseSchema.AsyncOperation.StatusCodeFailed + or DataverseSchema.AsyncOperation.StatusCodeCanceled; + + DateTime? createdOn = ReadUtc(e, DataverseSchema.AsyncOperation.CreatedOn); + DateTime? startedOn = ReadUtc(e, DataverseSchema.AsyncOperation.StartedOn); + DateTime? completedOn = ReadUtc(e, DataverseSchema.AsyncOperation.CompletedOn); + + var regarding = e.GetAttributeValue(DataverseSchema.AsyncOperation.RegardingObjectId); + + Guid? correlationId = DataverseEntityRead.ReadGuid(e, DataverseSchema.AsyncOperation.CorrelationId); + + return new AsyncJobRecord( + Id: e.Id, + Name: e.GetAttributeValue(DataverseSchema.AsyncOperation.Name), + OperationTypeCode: operationType, + OperationTypeLabel: operationTypeLabel, + StatusLabel: statusLabel, + IsError: isError, + CreatedOnUtc: createdOn, + StartedOnUtc: startedOn, + CompletedOnUtc: completedOn, + RegardingEntity: regarding?.LogicalName, + Message: e.GetAttributeValue(DataverseSchema.AsyncOperation.Message) + ?? e.GetAttributeValue(DataverseSchema.AsyncOperation.FriendlyMessage), + CorrelationId: correlationId); + } + + private static DateTime? ReadUtc(Entity e, string attribute) => + e.Contains(attribute) + ? DataverseDateTime.EnsureUtc(e.GetAttributeValue(attribute)) + : null; +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/DataverseEntityRead.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/DataverseEntityRead.cs new file mode 100644 index 00000000..e3f7841e --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/DataverseEntityRead.cs @@ -0,0 +1,28 @@ +using Microsoft.Xrm.Sdk; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +/// +/// Small helpers for reading attribute values off an in a +/// shape-tolerant way. Centralizes conversions that would otherwise be +/// copy-pasted into every reader's ToRecord. +/// +internal static class DataverseEntityRead +{ + /// + /// Reads a -valued attribute. Dataverse may surface a + /// uniqueidentifier column as a or as a string + /// (e.g. on Web API / virtual entities); both are handled. Returns + /// null when the attribute is absent or unparseable. + /// + public static Guid? ReadGuid(Entity entity, string attribute) + { + if (!entity.Contains(attribute)) return null; + return entity[attribute] switch + { + Guid g => g, + string s when Guid.TryParse(s, out var parsed) => parsed, + _ => null, + }; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceLogReader.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceLogReader.cs new file mode 100644 index 00000000..cb5b8c5d --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Sdk/PluginTraceLogReader.cs @@ -0,0 +1,153 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Sdk; + +/// +/// Reader for the standard plugintracelog table. Because it is a normal +/// table (not a virtual one), every filter is pushed server-side via +/// conditions. +/// +public sealed class PluginTraceLogReader +{ + private const string EntityName = DataverseSchema.PluginTraceLog.EntityName; + private static readonly ColumnSet Columns = new( + DataverseSchema.PluginTraceLog.PluginTraceLogId, + DataverseSchema.PluginTraceLog.CreatedOn, + DataverseSchema.PluginTraceLog.TypeName, + DataverseSchema.PluginTraceLog.MessageName, + DataverseSchema.PluginTraceLog.PrimaryEntity, + DataverseSchema.PluginTraceLog.Mode, + DataverseSchema.PluginTraceLog.Depth, + DataverseSchema.PluginTraceLog.OperationType, + DataverseSchema.PluginTraceLog.ExceptionDetails, + DataverseSchema.PluginTraceLog.MessageBlock, + DataverseSchema.PluginTraceLog.CorrelationId, + DataverseSchema.PluginTraceLog.PerformanceExecutionDuration); + + private readonly IOrganizationServiceAsync2 _service; + private readonly ILogger? _logger; + + public PluginTraceLogReader(IOrganizationServiceAsync2 service, ILogger? logger = null) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _logger = logger; + } + + /// + /// Returns recent plug-in trace rows ordered by createdon descending, + /// applying every condition in server-side. + /// + public async Task> GetRecentAsync( + EnvironmentLogFilter filter, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(filter); + + var q = new QueryExpression(EntityName) + { + ColumnSet = Columns, + Criteria = new FilterExpression(LogicalOperator.And), + TopCount = filter.Top > 0 ? filter.Top : 50, + }; + q.AddOrder(DataverseSchema.PluginTraceLog.CreatedOn, OrderType.Descending); + + if (filter.SinceUtc is { } since) + { + q.Criteria.AddCondition( + DataverseSchema.PluginTraceLog.CreatedOn, + ConditionOperator.GreaterEqual, + DataverseDateTime.EnsureUtc(since)); + } + + if (!string.IsNullOrWhiteSpace(filter.Plugin)) + { + q.Criteria.AddCondition( + DataverseSchema.PluginTraceLog.TypeName, + ConditionOperator.Like, + $"%{filter.Plugin.Trim()}%"); + } + + if (!string.IsNullOrWhiteSpace(filter.Entity)) + { + q.Criteria.AddCondition( + DataverseSchema.PluginTraceLog.PrimaryEntity, + ConditionOperator.Equal, + filter.Entity.Trim().ToLowerInvariant()); + } + + if (filter.ErrorsOnly) + { + q.Criteria.AddCondition( + DataverseSchema.PluginTraceLog.ExceptionDetails, + ConditionOperator.NotNull); + } + + if (filter.CorrelationId is { } correlationId) + { + q.Criteria.AddCondition( + DataverseSchema.PluginTraceLog.CorrelationId, + ConditionOperator.Equal, + correlationId); + } + + var res = await _service.RetrieveMultipleAsync(q, ct).ConfigureAwait(false); + return res.Entities.Select(ToRecord).ToList(); + } + + internal static PluginTraceRecord ToRecord(Entity e) + { + DateTime? createdOn = e.Contains(DataverseSchema.PluginTraceLog.CreatedOn) + ? DataverseDateTime.EnsureUtc(e.GetAttributeValue(DataverseSchema.PluginTraceLog.CreatedOn)) + : null; + + string? mode = e.FormattedValues.TryGetValue(DataverseSchema.PluginTraceLog.Mode, out var modeFmt) + && !string.IsNullOrWhiteSpace(modeFmt) + ? modeFmt + : e.GetAttributeValue(DataverseSchema.PluginTraceLog.Mode)?.Value.ToString(); + + int? depth = e.Contains(DataverseSchema.PluginTraceLog.Depth) + ? e.GetAttributeValue(DataverseSchema.PluginTraceLog.Depth) + : null; + + long? duration = e.Contains(DataverseSchema.PluginTraceLog.PerformanceExecutionDuration) + ? e.GetAttributeValue(DataverseSchema.PluginTraceLog.PerformanceExecutionDuration) + : null; + + var exception = e.GetAttributeValue(DataverseSchema.PluginTraceLog.ExceptionDetails); + var message = e.GetAttributeValue(DataverseSchema.PluginTraceLog.MessageBlock); + + Guid? correlationId = DataverseEntityRead.ReadGuid(e, DataverseSchema.PluginTraceLog.CorrelationId); + + return new PluginTraceRecord( + Id: e.Id, + CreatedOnUtc: createdOn, + TypeName: e.GetAttributeValue(DataverseSchema.PluginTraceLog.TypeName), + MessageName: e.GetAttributeValue(DataverseSchema.PluginTraceLog.MessageName), + PrimaryEntity: e.GetAttributeValue(DataverseSchema.PluginTraceLog.PrimaryEntity), + Mode: mode, + Depth: depth, + DurationMs: duration, + HasException: !string.IsNullOrWhiteSpace(exception), + ExceptionSnippet: Snippet(exception), + MessageSnippet: Snippet(message), + CorrelationId: correlationId); + } + + /// + /// Collapses a multi-line trace/exception block to a single trimmed line capped + /// at 200 characters, so it fits a table cell and a JSON summary alike. + /// + internal static string? Snippet(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var oneLine = value.Replace("\r", " ").Replace("\n", " ").Trim(); + while (oneLine.Contains(" ", StringComparison.Ordinal)) + oneLine = oneLine.Replace(" ", " ", StringComparison.Ordinal); + return oneLine.Length > 200 ? oneLine[..199] + "…" : oneLine; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEnvironmentLogService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEnvironmentLogService.cs new file mode 100644 index 00000000..4884c816 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseEnvironmentLogService.cs @@ -0,0 +1,80 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application.Sdk; +using TALXIS.CLI.Platform.Dataverse.Runtime; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +/// +/// Dataverse implementation of . Connects via +/// and delegates to the per-table readers. +/// +internal sealed class DataverseEnvironmentLogService : IEnvironmentLogService +{ + public async Task> GetPluginTracesAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseEnvironmentLogService)); + return await new PluginTraceLogReader(conn.Client, logger).GetRecentAsync(filter, ct).ConfigureAwait(false); + } + + public Task> GetAsyncJobsAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct) => + ReadAsyncJobsAsync(profileName, filter, operationTypeFilter: null, ct); + + public Task> GetWorkflowRunsAsync( + string? profileName, + EnvironmentLogFilter filter, + CancellationToken ct) => + ReadAsyncJobsAsync(profileName, filter, DataverseSchema.AsyncOperation.OperationTypeWorkflow, ct); + + private static async Task> ReadAsyncJobsAsync( + string? profileName, + EnvironmentLogFilter filter, + int? operationTypeFilter, + CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var logger = TxcLoggerFactory.CreateLogger(nameof(DataverseEnvironmentLogService)); + return await new AsyncOperationReader(conn.Client, logger) + .GetRecentAsync(filter, operationTypeFilter, ct).ConfigureAwait(false); + } + + public async Task CreateAsyncJobReaderAsync( + string? profileName, + int? operationTypeFilter, + CancellationToken ct) + { + // Resolve + connect ONCE; the returned reader polls on this open connection. + var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + return new AsyncJobLogReader(conn, operationTypeFilter); + } + + /// + /// Reader bound to a single open so a + /// --follow loop polls without re-resolving or re-connecting each tick. + /// + private sealed class AsyncJobLogReader : IAsyncJobLogReader + { + private readonly DataverseConnection _conn; + private readonly AsyncOperationReader _reader; + private readonly int? _operationTypeFilter; + + public AsyncJobLogReader(DataverseConnection conn, int? operationTypeFilter) + { + _conn = conn; + _operationTypeFilter = operationTypeFilter; + _reader = new AsyncOperationReader(conn.Client, TxcLoggerFactory.CreateLogger(nameof(AsyncJobLogReader))); + } + + public Task> ReadAsync(EnvironmentLogFilter filter, CancellationToken ct) + => _reader.GetRecentAsync(filter, _operationTypeFilter, ct); + + public void Dispose() => _conn.Dispose(); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Diagnostics/EnvLogFilterBuilderTests.cs b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/EnvLogFilterBuilderTests.cs new file mode 100644 index 00000000..a2aea9c6 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/EnvLogFilterBuilderTests.cs @@ -0,0 +1,71 @@ +using TALXIS.CLI.Features.Environment.Diagnostics; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Diagnostics; + +public class EnvLogFilterBuilderTests +{ + [Theory] + [InlineData("abc")] + [InlineData("10x")] + [InlineData("h")] + [InlineData("-5d")] + public void TryBuild_RejectsMalformedSince(string since) + { + var ok = EnvLogFilterBuilder.TryBuild(since, null, null, false, null, null, out _, out var error); + + Assert.False(ok); + Assert.Contains("--since", error); + } + + [Fact] + public void TryBuild_RejectsMalformedCorrelationId() + { + var ok = EnvLogFilterBuilder.TryBuild(null, null, null, false, "not-a-guid", null, out _, out var error); + + Assert.False(ok); + Assert.Contains("--correlation-id", error); + } + + [Fact] + public void TryBuild_DefaultsTopTo50WithoutSince() + { + var ok = EnvLogFilterBuilder.TryBuild(null, null, null, false, null, null, out var filter, out _); + + Assert.True(ok); + Assert.Equal(50, filter.Top); + Assert.Null(filter.SinceUtc); + } + + [Fact] + public void TryBuild_DefaultsTopTo200WithSince() + { + var ok = EnvLogFilterBuilder.TryBuild("24h", null, null, false, null, null, out var filter, out _); + + Assert.True(ok); + Assert.Equal(200, filter.Top); + Assert.NotNull(filter.SinceUtc); + } + + [Fact] + public void TryBuild_HonoursExplicitTop() + { + var ok = EnvLogFilterBuilder.TryBuild("24h", null, null, false, null, 5, out var filter, out _); + + Assert.True(ok); + Assert.Equal(5, filter.Top); + } + + [Fact] + public void TryBuild_ParsesFiltersAndCorrelationId() + { + var id = Guid.NewGuid(); + var ok = EnvLogFilterBuilder.TryBuild(null, " account ", " Acme.Plugin ", true, id.ToString(), null, out var filter, out _); + + Assert.True(ok); + Assert.Equal("account", filter.Entity); + Assert.Equal("Acme.Plugin", filter.Plugin); + Assert.True(filter.ErrorsOnly); + Assert.Equal(id, filter.CorrelationId); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogFollowTests.cs b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogFollowTests.cs new file mode 100644 index 00000000..ff29ed52 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogFollowTests.cs @@ -0,0 +1,64 @@ +using TALXIS.CLI.Features.Environment.Diagnostics; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Diagnostics; + +public class FollowSupportTests +{ + [Fact] + public void TryParseInterval_DefaultsTo5s_WhenEmpty() + { + Assert.True(FollowSupport.TryParseInterval(null, out var interval, out _)); + Assert.Equal(TimeSpan.FromSeconds(5), interval); + } + + [Fact] + public void TryParseInterval_ParsesSeconds() + { + Assert.True(FollowSupport.TryParseInterval("10", out var interval, out _)); + Assert.Equal(TimeSpan.FromSeconds(10), interval); + } + + [Theory] + [InlineData("abc")] + [InlineData("1")] + public void TryParseInterval_RejectsBadOrTooSmall(string value) + { + Assert.False(FollowSupport.TryParseInterval(value, out _, out var error)); + Assert.NotNull(error); + } +} + +public class FollowTrackerTests +{ + [Fact] + public void SelectNew_FirstBatch_ReturnsAllOldestFirst() + { + var tracker = new FollowTracker(s => s); + // readers return newest-first + var fresh = tracker.SelectNew(new[] { "b", "a" }); + + Assert.Equal(new[] { "a", "b" }, fresh); + } + + [Fact] + public void SelectNew_SecondBatch_ReturnsOnlyUnseen() + { + var tracker = new FollowTracker(s => s); + tracker.SelectNew(new[] { "b", "a" }); + + var fresh = tracker.SelectNew(new[] { "c", "b", "a" }); + + Assert.Equal(new[] { "c" }, fresh); + } + + [Fact] + public void SelectNew_SkipsItemsWithEmptyKey() + { + var tracker = new FollowTracker(_ => null); + + var fresh = tracker.SelectNew(new[] { "x", "y" }); + + Assert.Empty(fresh); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogListCliCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogListCliCommandTests.cs new file mode 100644 index 00000000..3444f03b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Diagnostics/LogListCliCommandTests.cs @@ -0,0 +1,70 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Features.Environment.Diagnostics; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Diagnostics; + +public class LogListCliCommandTests +{ + private static PluginTraceRecord Trace(DateTime createdOn, bool hasException) => + new(Guid.NewGuid(), createdOn, "Acme.Plugins.Account", "Update", "account", + "Synchronous", 1, 12, hasException, hasException ? "boom" : null, "trace", null); + + private static AsyncJobRecord Job(DateTime createdOn, bool isError) => + new(Guid.NewGuid(), "job", 10, "Workflow", isError ? "Failed" : "Succeeded", isError, + createdOn, createdOn, createdOn.AddSeconds(2), "account", isError ? "err" : null, null); + + [Fact] + public void BuildRows_InterleavesBothSourcesByTimestampDesc() + { + var older = DateTime.UtcNow.AddHours(-2); + var newer = DateTime.UtcNow.AddHours(-1); + + var rows = LogListCliCommand.BuildRows( + new[] { Trace(older, hasException: false) }, + new[] { Job(newer, isError: false) }, + errorsOnly: false); + + Assert.Equal(2, rows.Count); + Assert.Equal("async", rows[0].Source); + Assert.Equal("plugin", rows[1].Source); + } + + [Theory] + [InlineData(true, "ERROR")] + [InlineData(false, "OK")] + public void BuildRows_MapsPluginExceptionToLevel(bool hasException, string expected) + { + var rows = LogListCliCommand.BuildRows( + new[] { Trace(DateTime.UtcNow, hasException) }, + Array.Empty(), + errorsOnly: false); + + Assert.Equal(expected, rows[0].Level); + } + + [Theory] + [InlineData(true, "ERROR")] + [InlineData(false, "OK")] + public void BuildRows_MapsAsyncErrorToLevel(bool isError, string expected) + { + var rows = LogListCliCommand.BuildRows( + Array.Empty(), + new[] { Job(DateTime.UtcNow, isError) }, + errorsOnly: false); + + Assert.Equal(expected, rows[0].Level); + } + + [Fact] + public void BuildRows_ErrorsOnly_KeepsOnlyErrorRows() + { + var rows = LogListCliCommand.BuildRows( + new[] { Trace(DateTime.UtcNow, hasException: false), Trace(DateTime.UtcNow, hasException: true) }, + new[] { Job(DateTime.UtcNow, isError: false), Job(DateTime.UtcNow, isError: true) }, + errorsOnly: true); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal("ERROR", r.Level)); + } +}