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.Core/Contracts/Dataverse/IFlowRunLogService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IFlowRunLogService.cs new file mode 100644 index 00000000..4ce4f2a6 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IFlowRunLogService.cs @@ -0,0 +1,46 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// One cloud flow run from the Dataverse flowrun virtual table (the +/// table proxies the flow service's run history). All +/// values are UTC. Per-action details are not exposed by the table — they +/// only exist in the flow API, which requires a preauthorized first-party +/// client this CLI does not have. +/// +public sealed record FlowRunRecord( + string RunId, + string? Status, + string? TriggerType, + DateTime? StartTimeUtc, + DateTime? EndTimeUtc, + int? DurationMs, + string? ErrorCode, + string? ErrorMessage); + +/// +/// Reads cloud flow (Power Automate) run history. Modern flow runs do not +/// live in Dataverse log tables like asyncoperation; they are read +/// from the flowrun virtual table. +/// +public interface IFlowRunLogService +{ + /// + /// Lists recent runs of one cloud flow, newest first. + /// is the flow's name or workflowid GUID. + /// + Task> ListRunsAsync( + string? profileName, + string flow, + int top, + string? status, + CancellationToken ct); + + /// + /// Returns one run of the flow, or null when the run id does not exist. + /// + Task GetRunAsync( + string? profileName, + string flow, + string runId, + 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..afabfa9e --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogCliCommand.cs @@ -0,0 +1,28 @@ +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), + typeof(LogFlowRunsCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class LogCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFlowRunsCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFlowRunsCliCommand.cs new file mode 100644 index 00000000..24857155 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Diagnostics/LogFlowRunsCliCommand.cs @@ -0,0 +1,126 @@ +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 cloud flow (Power Automate) run history. Modern flow runs live in the +/// flow service, not in asyncoperation; they are read through the +/// Dataverse flowrun virtual table. +/// +/// +/// txc environment log flow-runs --flow tst_oncontactchange +/// txc env log flow-runs --flow tst_oncontactchange --status Failed --top 5 +/// txc env log flow-runs --flow tst_oncontactchange --run-id 08585287554388203450022458732CU00 +/// +[CliReadOnly] +[CliCommand( + Name = "flow-runs", + Description = "Read cloud flow (Power Automate) run history from the LIVE environment. Requires an active profile." +)] +public class LogFlowRunsCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LogFlowRunsCliCommand)); + + private static readonly string[] KnownStatuses = ["Succeeded", "Failed", "Running", "Cancelled"]; + + private const int DefaultTop = 10; + + [CliOption(Name = "--flow", Description = "Cloud flow name or workflowid GUID (workflow table, category 5).", Required = true)] + public string Flow { get; set; } = null!; + + [CliOption(Name = "--run-id", Description = "Show one run instead of the run list.", Required = false)] + public string? RunId { get; set; } + + [CliOption(Name = "--status", Description = "Filter runs by status (Succeeded, Failed, Running, Cancelled).", Required = false)] + public string? Status { get; set; } + + [CliOption(Name = "--top", Description = "Maximum number of runs to return.", Required = false)] + public int? Top { get; set; } + + protected override async Task ExecuteAsync() + { + string? status = null; + if (!string.IsNullOrWhiteSpace(Status)) + { + status = KnownStatuses.FirstOrDefault(s => string.Equals(s, Status, StringComparison.OrdinalIgnoreCase)); + if (status is null) + { + Logger.LogError( + "Invalid --status '{Status}'. Expected one of: Succeeded, Failed, Running, Cancelled.", Status); + return ExitValidationError; + } + } + + var service = TxcServices.Get(); + + if (!string.IsNullOrWhiteSpace(RunId)) + { + FlowRunRecord? run = await service.GetRunAsync(Profile, Flow, RunId, CancellationToken.None).ConfigureAwait(false); + if (run is null) + { + Logger.LogError( + "Run '{RunId}' was not found on flow '{Flow}'. Use 'txc environment log flow-runs --flow {Flow}' to list recent runs.", + RunId, Flow, Flow); + return ExitValidationError; + } + + OutputFormatter.WriteData(run, PrintDetail); + return ExitSuccess; + } + + IReadOnlyList runs = await service + .ListRunsAsync(Profile, Flow, Top ?? DefaultTop, status, CancellationToken.None) + .ConfigureAwait(false); + + OutputFormatter.WriteList(runs, PrintTable); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList runs) + { + if (runs.Count == 0) + { + OutputWriter.WriteLine("No flow runs found."); + return; + } + + int idWidth = Math.Clamp(runs.Max(r => r.RunId.Length), 10, 40); + string header = $"{"Run Id".PadRight(idWidth)} | {"Status".PadRight(10)} | {"Trigger".PadRight(10)} | {"Started (UTC)".PadRight(20)} | {"Duration".PadRight(10)} | Error"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + + foreach (var r in runs) + { + string id = r.RunId.Length > idWidth ? r.RunId[..(idWidth - 1)] + "." : r.RunId; + string error = r.ErrorCode is null ? string.Empty : $"{r.ErrorCode}: {r.ErrorMessage}"; + string duration = r.DurationMs is { } ms ? $"{ms} ms" : string.Empty; + OutputWriter.WriteLine( + $"{id.PadRight(idWidth)} | {(r.Status ?? string.Empty).PadRight(10)} | {(r.TriggerType ?? string.Empty).PadRight(10)} | {Format(r.StartTimeUtc).PadRight(20)} | {duration.PadRight(10)} | {error}"); + } + } + + private static void PrintDetail(FlowRunRecord r) + { + const int labelWidth = -10; + OutputWriter.WriteLine($"{"Run Id:",labelWidth}{r.RunId}"); + OutputWriter.WriteLine($"{"Status:",labelWidth}{r.Status}"); + OutputWriter.WriteLine($"{"Trigger:",labelWidth}{r.TriggerType}"); + OutputWriter.WriteLine($"{"Started:",labelWidth}{Format(r.StartTimeUtc)}"); + OutputWriter.WriteLine($"{"Ended:",labelWidth}{Format(r.EndTimeUtc)}"); + if (r.DurationMs is { } ms) + OutputWriter.WriteLine($"{"Duration:",labelWidth}{ms} ms"); + if (r.ErrorCode is not null) + OutputWriter.WriteLine($"{"Error:",labelWidth}{r.ErrorCode}: {r.ErrorMessage}"); + } + + private static string Format(DateTime? utc) + => utc?.ToString("yyyy-MM-dd HH:mm:ss") ?? string.Empty; +#pragma warning restore TXC003 +} 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..5cb87ddc 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/DataverseApplicationServiceCollectionExtensions.cs @@ -18,6 +18,8 @@ public static IServiceCollection AddTxcDataverseApplication(this IServiceCollect services.AddSingleton(); 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/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseFlowRunLogService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseFlowRunLogService.cs new file mode 100644 index 00000000..febb517b --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseFlowRunLogService.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Services; + +/// +/// Reads cloud flow run history from the Dataverse flowrun virtual +/// table. The flow is resolved from the workflow table (category 5) +/// by name or workflowid, then runs are queried by the workflowid attribute. +/// Everything runs under the ordinary Dataverse token — no flow-service +/// audience is required (which this CLI's client id could not acquire anyway). +/// +public sealed class DataverseFlowRunLogService : IFlowRunLogService +{ + private const string RunColumns = "name,status,triggertype,starttime,endtime,duration,errorcode,errormessage"; + + private readonly IDataverseQueryService _query; + + public DataverseFlowRunLogService(IDataverseQueryService query) + { + _query = query ?? throw new ArgumentNullException(nameof(query)); + } + + public async Task> ListRunsAsync( + string? profileName, string flow, int top, string? status, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(flow); + + var workflowId = await ResolveWorkflowIdAsync(profileName, flow, ct).ConfigureAwait(false); + + var filter = $"workflowid eq {workflowId:D}"; + if (!string.IsNullOrWhiteSpace(status)) + filter += $" and status eq '{status.Replace("'", "''")}'"; + + var result = await _query.QueryODataAsync( + profileName, + "flowruns", + select: RunColumns, + filter: filter, + orderBy: "starttime desc", + top: top, + includeAnnotations: false, + ct).ConfigureAwait(false); + + return result.Records.Select(ParseRun).ToList(); + } + + public async Task GetRunAsync( + string? profileName, string flow, string runId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(flow); + ArgumentException.ThrowIfNullOrWhiteSpace(runId); + + var workflowId = await ResolveWorkflowIdAsync(profileName, flow, ct).ConfigureAwait(false); + + var result = await _query.QueryODataAsync( + profileName, + "flowruns", + select: RunColumns, + filter: $"workflowid eq {workflowId:D} and name eq '{runId.Replace("'", "''")}'", + orderBy: null, + top: 1, + includeAnnotations: false, + ct).ConfigureAwait(false); + + return result.Records.Count == 0 ? null : ParseRun(result.Records[0]); + } + + private async Task ResolveWorkflowIdAsync(string? profileName, string flow, CancellationToken ct) + { + var condition = Guid.TryParse(flow, out var workflowId) + ? $"workflowid = '{workflowId:D}'" + : $"name = '{flow.Replace("'", "''")}'"; + var sql = $"SELECT workflowid, name FROM workflow WHERE {condition} AND category = 5"; + + var result = await _query.QuerySqlAsync(profileName, sql, top: 5, includeAnnotations: false, ct).ConfigureAwait(false); + if (result.Records.Count == 0) + { + throw new InvalidOperationException( + $"No cloud flow matching '{flow}' was found in the environment. Pass the flow's name or workflowid (workflow table, category 5)."); + } + + if (result.Records.Count > 1) + { + throw new InvalidOperationException( + $"Multiple cloud flows are named '{flow}'. Pass the workflowid GUID instead."); + } + + var resolved = ReadString(result.Records[0], "workflowid"); + if (!Guid.TryParse(resolved, out var id)) + throw new InvalidOperationException($"Cloud flow '{flow}' returned an unreadable workflowid."); + + return id; + } + + private static FlowRunRecord ParseRun(JsonElement run) + => new( + RunId: ReadString(run, "name") ?? string.Empty, + Status: ReadString(run, "status"), + TriggerType: ReadString(run, "triggertype"), + StartTimeUtc: ReadUtc(run, "starttime"), + EndTimeUtc: ReadUtc(run, "endtime"), + DurationMs: ReadInt(run, "duration"), + ErrorCode: ReadString(run, "errorcode"), + ErrorMessage: ReadString(run, "errormessage")); + + private static string? ReadString(JsonElement element, string property) + => element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(property, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTime? ReadUtc(JsonElement element, string property) + => ReadString(element, property) is { } raw + && DateTimeOffset.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed) + ? parsed.UtcDateTime + : null; + + private static int? ReadInt(JsonElement element, string property) + => element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(property, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out var parsed) + ? parsed + : null; +} diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/FlowRunLogServiceTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/FlowRunLogServiceTests.cs new file mode 100644 index 00000000..8e7b4290 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/FlowRunLogServiceTests.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Application.Services; +using Xunit; + +namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform; + +public sealed class FlowRunLogServiceTests +{ + private static readonly Guid WorkflowId = Guid.Parse("98b4ebbb-3cbf-4021-b5f8-ab8c4ce0988e"); + + private static readonly string WorkflowRow = + $$"""{"workflowid":"{{WorkflowId}}","name":"tst_hourlyjob"}"""; + + private const string RunRow = """ + { + "name": "08584164478908761312693862248CU10", + "status": "Failed", + "triggertype": "Scheduled", + "starttime": "2026-07-27T13:29:54Z", + "endtime": "2026-07-27T13:29:55Z", + "duration": 89, + "errorcode": "ActionFailed", + "errormessage": "An action failed." + } + """; + + [Fact] + public async Task ListRunsAsync_ResolvesFlowByName_AndQueriesFlowRuns() + { + var query = new FakeQueryService(new[] { WorkflowRow }, new[] { RunRow }); + var sut = new DataverseFlowRunLogService(query); + + var runs = await sut.ListRunsAsync(null, "tst_hourlyjob", top: 10, status: null, CancellationToken.None); + + Assert.Contains("name = 'tst_hourlyjob'", query.LastSql); + Assert.Contains("category = 5", query.LastSql); + Assert.Equal("flowruns", query.LastEntity); + Assert.Equal($"workflowid eq {WorkflowId:D}", query.LastFilter); + Assert.Equal("starttime desc", query.LastOrderBy); + Assert.Equal(10, query.LastTop); + + var run = Assert.Single(runs); + Assert.Equal("08584164478908761312693862248CU10", run.RunId); + Assert.Equal("Failed", run.Status); + Assert.Equal("Scheduled", run.TriggerType); + Assert.Equal(89, run.DurationMs); + Assert.Equal("ActionFailed", run.ErrorCode); + Assert.Equal(new DateTime(2026, 7, 27, 13, 29, 54, DateTimeKind.Utc), run.StartTimeUtc); + } + + [Fact] + public async Task ListRunsAsync_ResolvesFlowByGuid_AndAppendsStatusFilter() + { + var query = new FakeQueryService(new[] { WorkflowRow }, Array.Empty()); + var sut = new DataverseFlowRunLogService(query); + + await sut.ListRunsAsync(null, WorkflowId.ToString(), top: 5, status: "Failed", CancellationToken.None); + + Assert.Contains($"workflowid = '{WorkflowId:D}'", query.LastSql); + Assert.Equal($"workflowid eq {WorkflowId:D} and status eq 'Failed'", query.LastFilter); + } + + [Fact] + public async Task GetRunAsync_FiltersByRunId_AndReturnsSingleRun() + { + var query = new FakeQueryService(new[] { WorkflowRow }, new[] { RunRow }); + var sut = new DataverseFlowRunLogService(query); + + var run = await sut.GetRunAsync(null, "tst_hourlyjob", "08584164478908761312693862248CU10", CancellationToken.None); + + Assert.NotNull(run); + Assert.Contains("name eq '08584164478908761312693862248CU10'", query.LastFilter); + Assert.Equal(1, query.LastTop); + } + + [Fact] + public async Task GetRunAsync_ReturnsNull_WhenRunNotFound() + { + var query = new FakeQueryService(new[] { WorkflowRow }, Array.Empty()); + var sut = new DataverseFlowRunLogService(query); + + var run = await sut.GetRunAsync(null, "tst_hourlyjob", "missing", CancellationToken.None); + + Assert.Null(run); + } + + [Fact] + public async Task ListRunsAsync_Throws_WhenFlowNotFound() + { + var query = new FakeQueryService(Array.Empty(), Array.Empty()); + var sut = new DataverseFlowRunLogService(query); + + var ex = await Assert.ThrowsAsync( + () => sut.ListRunsAsync(null, "nope", top: 10, status: null, CancellationToken.None)); + Assert.Contains("No cloud flow matching", ex.Message); + } + + [Fact] + public async Task ListRunsAsync_Throws_WhenFlowNameAmbiguous() + { + var query = new FakeQueryService(new[] { WorkflowRow, WorkflowRow }, Array.Empty()); + var sut = new DataverseFlowRunLogService(query); + + var ex = await Assert.ThrowsAsync( + () => sut.ListRunsAsync(null, "dup", top: 10, status: null, CancellationToken.None)); + Assert.Contains("Multiple cloud flows", ex.Message); + } + + [Fact] + public async Task ListRunsAsync_EscapesQuotes_InFlowNameAndStatus() + { + var query = new FakeQueryService(new[] { WorkflowRow }, Array.Empty()); + var sut = new DataverseFlowRunLogService(query); + + await sut.ListRunsAsync(null, "o'brien", top: 5, status: "Fail'ed", CancellationToken.None); + + Assert.Contains("name = 'o''brien'", query.LastSql); + Assert.Contains("status eq 'Fail''ed'", query.LastFilter); + } + + private sealed class FakeQueryService : IDataverseQueryService + { + private readonly IReadOnlyList _sqlRecords; + private readonly IReadOnlyList _odataRecords; + + public string? LastSql { get; private set; } + public string? LastEntity { get; private set; } + public string? LastFilter { get; private set; } + public string? LastOrderBy { get; private set; } + public int? LastTop { get; private set; } + + public FakeQueryService(IReadOnlyList sqlRows, IReadOnlyList odataRows) + { + _sqlRecords = Parse(sqlRows); + _odataRecords = Parse(odataRows); + } + + private static IReadOnlyList Parse(IReadOnlyList rows) + => rows.Select(r => JsonDocument.Parse(r).RootElement.Clone()).ToList(); + + public Task QuerySqlAsync(string? profileName, string sql, int? top, bool includeAnnotations, CancellationToken ct) + { + LastSql = sql; + return Task.FromResult(new DataverseQueryResult(_sqlRecords, _sqlRecords.Count)); + } + + public Task QueryFetchXmlAsync(string? profileName, string fetchXml, int? top, bool includeAnnotations, CancellationToken ct) + => throw new NotSupportedException(); + + public Task QueryODataAsync(string? profileName, string entitySetOrPath, string? select, string? filter, string? orderBy, int? top, bool includeAnnotations, CancellationToken ct) + { + LastEntity = entitySetOrPath; + LastFilter = filter; + LastOrderBy = orderBy; + LastTop = top; + return Task.FromResult(new DataverseQueryResult(_odataRecords, _odataRecords.Count)); + } + } +} 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)); + } +}