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));
+ }
+}