using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace D365MetadataBridge.Services.Trace
{
///
/// `D365MetadataBridge.exe --analyze-trace <file.etl> --out <result.json> [options]`
///
/// Runs as its own short-lived process, started by the MCP server, so that an analysis taking
/// minutes on a multi-GB capture never holds the long-running bridge or its request queue.
/// It touches no D365FO assembly. Options:
/// --focus "A;B" method-name substrings that select the threads to analyse
/// --pid N only this process (e.g. Batch.exe)
/// --scope thread|subtree
/// --top N rows per ranking (default 25)
/// --repeat N executions from one caller that count as repeated SQL (default 50)
/// --progress file JSON progress, rewritten every 250,000 events
/// The result is written to --out atomically (temp file, then rename). On failure --out holds
/// {"error": "..."} and the exit code is non-zero.
///
public static class TraceAnalysisCommand
{
private static readonly JsonSerializerOptions Json = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public static int Run(string[] args)
{
string? etl = null, output = null, progressFile = null;
var options = new XppTraceOptions();
try
{
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException(arg + " needs a value.");
switch (arg)
{
case "--analyze-trace": etl = Next(); break;
case "--out": output = Next(); break;
case "--progress": progressFile = Next(); break;
case "--focus": options.Focus = Next().Split(';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); break;
case "--pid": options.ProcessId = int.Parse(Next(), CultureInfo.InvariantCulture); break;
case "--scope":
string scope = Next();
if (scope != "thread" && scope != "subtree") throw new ArgumentException("--scope must be thread or subtree.");
options.SubtreeOnly = scope == "subtree";
break;
case "--top": options.Top = Math.Max(1, Math.Min(100, int.Parse(Next(), CultureInfo.InvariantCulture))); break;
case "--repeat": options.RepeatedSqlThreshold = Math.Max(2, int.Parse(Next(), CultureInfo.InvariantCulture)); break;
default: throw new ArgumentException("Unknown argument: " + arg);
}
}
if (string.IsNullOrWhiteSpace(etl) || string.IsNullOrWhiteSpace(output))
throw new ArgumentException("Usage: --analyze-trace --out [--focus A;B] [--pid N] [--scope thread|subtree] [--top N] [--repeat N] [--progress file]");
if (options.SubtreeOnly && options.Focus.Count == 0) throw new ArgumentException("--scope subtree needs --focus.");
if (!File.Exists(etl)) throw new FileNotFoundException("Trace file not found: " + etl);
}
catch (Exception e) when (e is ArgumentException || e is FormatException || e is FileNotFoundException || e is OverflowException)
{
if (output != null) WriteAtomic(output, new { error = e.Message });
Console.Error.WriteLine(e.Message);
return 2;
}
var watch = Stopwatch.StartNew();
try
{
var aggregator = new XppTraceAggregator(options);
var stats = EtlTraceReader.Read(etl!, aggregator, read =>
{
if (progressFile == null) return;
// Best effort: the server polls this file, and a replace that collides with its read
// (or an antivirus scan) must not end an analysis that may have run for minutes.
try { WriteAtomic(progressFile, new { eventsRead = read, elapsedSeconds = Math.Round(watch.Elapsed.TotalSeconds, 1) }); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
});
var result = aggregator.Build();
result.File = Path.GetFullPath(etl!);
result.FileBytes = new FileInfo(etl!).Length;
result.EventsRead = stats.EventsRead;
result.EventsUnreadable = stats.EventsUnreadable;
result.AnalysisSeconds = Math.Round(watch.Elapsed.TotalSeconds, 1);
if (stats.EventsUnreadable > 0)
result.Caveats.Add($"{stats.EventsUnreadable} events could not be rendered (provider manifest missing or event corrupt) and were skipped.");
WriteAtomic(output!, result);
return 0;
}
catch (Exception e)
{
WriteAtomic(output!, new { error = e.GetType().Name + ": " + e.Message });
Console.Error.WriteLine(e);
return 3;
}
}
private static void WriteAtomic(string path, object value)
{
string full = Path.GetFullPath(path);
string temp = full + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(value, Json));
if (File.Exists(full)) File.Delete(full);
File.Move(temp, full);
}
}
}