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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cli/CliOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal sealed class CliOptions
public bool ContinueOnError { get; set; } = true;
public string MultiReportDirectory { get; set; } = string.Empty;
public string? MultiReportFilter { get; set; }
public bool MultiReportNoReports { get; set; }
public string GoldenPath { get; set; } = string.Empty;
public string? ValidationOutputPath { get; set; }
public string SummaryInputPath { get; set; } = string.Empty;
Expand Down
6 changes: 6 additions & 0 deletions Cli/CommandLineBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,16 @@ public static RootCommand Build(
{
Description = "Print progress and timings.",
};
var multiNoReportsOpt = new Option<bool>("--no-reports")
{
Description = "Skip generating per-snapshot drill-down reports (faster; rows are not clickable).",
};

multiReportCmd.Add(filterOpt);
multiReportCmd.Add(multiOutOpt);
multiReportCmd.Add(multiTitleOpt);
multiReportCmd.Add(multiVerboseOpt);
multiReportCmd.Add(multiNoReportsOpt);

multiReportCmd.SetAction((ParseResult parseResult) =>
{
Expand All @@ -267,6 +272,7 @@ public static RootCommand Build(
ReportOutputPath = string.IsNullOrWhiteSpace(outPath) ? null : ExpandPath(outPath!),
ReportTitle = parseResult.GetValue(multiTitleOpt)!,
Verbose = parseResult.GetValue(multiVerboseOpt),
MultiReportNoReports = parseResult.GetValue(multiNoReportsOpt),
};
return runMultiReport(options);
});
Expand Down
1 change: 1 addition & 0 deletions Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ private static int RunMultiReport(CliOptions options)
NameFilter = options.MultiReportFilter,
ReportOutputPath = options.ReportOutputPath,
ReportTitle = options.ReportTitle,
GenerateReports = !options.MultiReportNoReports,
};
var progress = new ConsoleProgress(options.Verbose);
return MultiSnapshotReportRunner.Run(multiOptions, progress);
Expand Down
425 changes: 339 additions & 86 deletions Core/Report/MultiSnapshotReport/MultiSnapshotHtmlRenderer.cs

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions Core/Report/MultiSnapshotReport/MultiSnapshotReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ private static SnapshotMetricsRow QueryDuckDb(string dbPath)
var snapshotMeta = QuerySnapshotMetadata(connection, isDuckDb: true);
var nativeTypes = QueryNativeTypes(connection, isDuckDb: true);
var remapperRoots = QueryRemapperRoots(connection, isDuckDb: true);
return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection));
var (summary, summaryAvailable) = SummaryMetricsDbReader.Read(connection);
return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection), summary, summaryAvailable);
}

private static SnapshotMetricsRow QuerySqlite(string dbPath)
Expand All @@ -89,7 +90,8 @@ private static SnapshotMetricsRow QuerySqlite(string dbPath)
var snapshotMeta = QuerySnapshotMetadata(connection, isDuckDb: false);
var nativeTypes = QueryNativeTypes(connection, isDuckDb: false);
var remapperRoots = QueryRemapperRoots(connection, isDuckDb: false);
return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection));
var (summary, summaryAvailable) = SummaryMetricsDbReader.Read(connection);
return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection), summary, summaryAvailable);
}

private static Dictionary<string, NativeTypeSnapshotMetrics> QueryNativeTypes(object connection, bool isDuckDb)
Expand Down Expand Up @@ -263,7 +265,9 @@ private static SnapshotMetricsRow BuildRow(
Dictionary<string, NativeTypeSnapshotMetrics> nativeTypes,
List<NativeRootSnapshotMetrics> remapperRoots,
DbSnapshotMetadata dbMeta,
(int Major, int Minor) schemaVersion)
(int Major, int Minor) schemaVersion,
SummaryMetrics summary,
bool summaryAvailable)
{
var fileName = Path.GetFileNameWithoutExtension(dbPath);
var meta = EnrichMetadata(dbPath, fileName, dbMeta);
Expand All @@ -284,6 +288,8 @@ private static SnapshotMetricsRow BuildRow(
SortTimestamp = meta.SortTimestamp,
NativeTypes = nativeTypes,
RemapperRoots = remapperRoots,
Summary = summaryAvailable ? summary : null,
SummaryAvailable = summaryAvailable,
SchemaVersion = DatabaseSchemaInfo.DescribeVersion(schemaVersion.Major, schemaVersion.Minor),
SchemaUpToDate = DatabaseSchemaInfo.Evaluate(schemaVersion.Major, schemaVersion.Minor) == SchemaAction.None,
};
Expand Down
9 changes: 9 additions & 0 deletions Core/Report/MultiSnapshotReport/MultiSnapshotReportModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ public sealed record SnapshotMetricsRow

/// <summary>Remapper / PMR root metrics (may be empty).</summary>
public IReadOnlyList<NativeRootSnapshotMetrics> RemapperRoots { get; init; } = [];

/// <summary>
/// MemoryProfiler "Summary" page metrics (Allocated Memory Distribution totals/breakdown) read from
/// the database's <c>summary_metrics</c> table, or null when that table is absent (older export).
/// </summary>
public SummaryMetrics? Summary { get; init; }

/// <summary>True when the database had a <c>summary_metrics</c> table to read from.</summary>
public bool SummaryAvailable { get; init; }
}

/// <summary>
Expand Down
136 changes: 121 additions & 15 deletions Core/Report/MultiSnapshotReport/MultiSnapshotReportRunner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using MemorySnapshotDataTools.Report.Queries;

namespace MemorySnapshotDataTools.Report.MultiSnapshotReport;

Expand All @@ -18,6 +19,12 @@ public sealed class MultiSnapshotReportRunOptions

/// <summary>HTML document title.</summary>
public string ReportTitle { get; set; } = "Multi-Snapshot Memory Report";

/// <summary>
/// When true (default), generate a full single-snapshot report for each database so rows can be
/// previewed in the inline drawer. Set false (<c>--no-reports</c>) for the faster table-only output.
/// </summary>
public bool GenerateReports { get; set; } = true;
}

/// <summary>
Expand All @@ -42,30 +49,28 @@ public static int Run(MultiSnapshotReportRunOptions options, IProgressReporter p
var model = MultiSnapshotReportBuilder.Build(options.Directory, options.NameFilter, options.ReportTitle);
swQuery.Stop();

var (outPath, reportsDir, reportsFolderName, openBrowser) = ResolveOutputLayout(options);

// Generate one full single-snapshot report per database so rows are clickable in the drawer.
var generatedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + " UTC";
var swReports = Stopwatch.StartNew();
var reportLinks = options.GenerateReports
? GenerateIndividualReports(model, reportsDir!, reportsFolderName!, generatedAtUtc, progress)
: new Dictionary<string, string>();
swReports.Stop();

var swRender = Stopwatch.StartNew();
var html = MultiSnapshotHtmlRenderer.Render(model);
var html = MultiSnapshotHtmlRenderer.Render(model, reportLinks);
swRender.Stop();

var outPath = options.ReportOutputPath;
var openBrowser = string.IsNullOrEmpty(outPath);
if (string.IsNullOrEmpty(outPath))
{
outPath = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8] + ".html");
}
else
{
var dir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}

var swWrite = Stopwatch.StartNew();
File.WriteAllText(outPath, html, System.Text.Encoding.UTF8);
swWrite.Stop();

var totalSnapshots = model.Sessions.Sum(s => s.Snapshots.Count);
progress.Report($"Report written → {outPath}", force: true);
progress.Report(
$"Timings: query_ms={swQuery.ElapsedMilliseconds}, render_ms={swRender.ElapsedMilliseconds}, write_ms={swWrite.ElapsedMilliseconds}",
$"Timings: query_ms={swQuery.ElapsedMilliseconds}, reports_ms={swReports.ElapsedMilliseconds} (reports={reportLinks.Count}/{totalSnapshots}), render_ms={swRender.ElapsedMilliseconds}, write_ms={swWrite.ElapsedMilliseconds}",
force: true);

if (openBrowser)
Expand All @@ -82,4 +87,105 @@ public static int Run(MultiSnapshotReportRunOptions options, IProgressReporter p

return 0;
}

/// <summary>
/// Resolves where the multi-report HTML and the per-snapshot reports are written. With <c>--out</c>,
/// reports go in a <c>&lt;basename&gt;_reports</c> folder beside it; otherwise a dedicated temp folder
/// holds <c>index.html</c> plus a <c>reports</c> subfolder (so the drawer's relative iframe links
/// resolve under one root). When reports are disabled and no <c>--out</c> is given, the original loose
/// temp file is used.
/// </summary>
private static (string OutPath, string? ReportsDir, string? ReportsFolderName, bool OpenBrowser) ResolveOutputLayout(
MultiSnapshotReportRunOptions options)
{
if (!string.IsNullOrEmpty(options.ReportOutputPath))
{
var outPath = options.ReportOutputPath;
var outDir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);

if (!options.GenerateReports)
return (outPath, null, null, false);

var folderName = Path.GetFileNameWithoutExtension(outPath) + "_reports";
var reportsDir = Path.Combine(outDir ?? string.Empty, folderName);
return (outPath, reportsDir, folderName, false);
}

if (!options.GenerateReports)
{
// Original behavior: a single loose temp file opened in the browser.
var loose = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8] + ".html");
return (loose, null, null, true);
}

var rootDir = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(rootDir);
return (Path.Combine(rootDir, "index.html"), Path.Combine(rootDir, "reports"), "reports", true);
}

/// <summary>
/// Builds and writes a full single-snapshot report for each database, returning a map from each
/// snapshot's database path to the relative href of its report (forward slashes). A database whose
/// report fails to build is skipped (and simply stays non-clickable), mirroring the per-DB skip in
/// <see cref="MultiSnapshotReportBuilder"/>.
/// </summary>
private static Dictionary<string, string> GenerateIndividualReports(
MultiSnapshotReportModel model,
string reportsDir,
string reportsFolderName,
string generatedAtUtc,
IProgressReporter progress)
{
Directory.CreateDirectory(reportsDir);

var links = new Dictionary<string, string>(StringComparer.Ordinal);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var snapshots = model.Sessions.SelectMany(s => s.Snapshots).ToList();
var done = 0;

foreach (var snap in snapshots)
{
done++;
var safeName = UniqueFileName(snap.SnapshotName, usedNames);
var reportPath = Path.Combine(reportsDir, safeName + ".html");
try
{
using var backend = ReportQueryFactory.Create(snap.DatabasePath);
var reportModel = ReportBuilder.Build(backend, $"{snap.SnapshotName} — Report", snap.DatabasePath, generatedAtUtc);
File.WriteAllText(reportPath, ReportRenderer.Render(reportModel), System.Text.Encoding.UTF8);
links[snap.DatabasePath] = reportsFolderName + "/" + safeName + ".html";
progress.Report($"Report {done}/{snapshots.Count}: {snap.SnapshotName}");
}
catch (Exception ex)
{
usedNames.Remove(safeName);
progress.Report($"Skipping report for {snap.SnapshotName}: {ex.Message}", force: true);
}
}

return links;
}

/// <summary>
/// Produces a filesystem- and URL-safe report filename from a snapshot name (conservative ASCII set),
/// disambiguating collisions across the batch with a numeric suffix.
/// </summary>
private static string UniqueFileName(string snapshotName, HashSet<string> usedNames)
{
var chars = snapshotName.Select(ch =>
char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.' ? ch : '_').ToArray();
var baseName = new string(chars).Trim('.', '_');
if (string.IsNullOrEmpty(baseName))
baseName = "snapshot";
if (baseName.Length > 120)
baseName = baseName[..120];

var candidate = baseName;
var suffix = 2;
while (!usedNames.Add(candidate))
candidate = $"{baseName}_{suffix++}";
return candidate;
}
}
6 changes: 6 additions & 0 deletions Core/Report/ReportRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ internal static class ReportRenderer
.kv-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: .05em; }
.kv-value { font-size: 15px; font-weight: 600; color: #1a1a2e; margin-top: 2px; }
.kv-value.mono { font-family: "SF Mono", "Fira Code", Consolas, monospace; font-size: 11px; font-weight: 400; color: #444; word-break: break-all; white-space: normal; }
/* When the viewport is too narrow for the fixed nav to sit in the right gutter (e.g. inside the
multi-report iframe drawer), drop it into normal flow at the top so it never overlaps content. */
@media (max-width: 1360px) {
nav { position: static; width: auto; max-width: 100%; max-height: 220px; margin: 0 0 20px; }
main { max-width: 100%; }
}
""";

/// <summary>Builds the full HTML document from the report model (nav, title, groups, sections).</summary>
Expand Down
72 changes: 72 additions & 0 deletions Core/Report/SummaryMetricsDbReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Data.Common;
using MemorySnapshotDataTools.Validation;

namespace MemorySnapshotDataTools.Report;

/// <summary>
/// Reads the <c>summary_metrics</c> table from an exported DuckDB or SQLite database into a
/// <see cref="SummaryMetrics"/>. Shared by the <c>summary</c> command and the multi-snapshot report so
/// the group-mapping and DuckDB/SQLite normalization live in one place. The query is the constant
/// <see cref="GoldenValidationQueries.SummaryMetricsSql"/> (no parameters, no identifier interpolation),
/// and a missing table yields <c>Available = false</c> rather than throwing — see docs/sql-safety.md.
/// </summary>
internal static class SummaryMetricsDbReader
{
/// <summary>
/// Reads <c>summary_metrics</c> from an already-open (ideally read-only) connection.
/// </summary>
/// <returns>The populated metrics and whether the table was present.</returns>
public static (SummaryMetrics Metrics, bool Available) Read(DbConnection connection)
{
var metrics = new SummaryMetrics();
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = GoldenValidationQueries.SummaryMetricsSql;
using var reader = cmd.ExecuteReader();

var any = false;
while (reader.Read())
{
any = true;
var group = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
var category = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
var committed = ToULong(DbScalarReader.GetInt64(reader, 2));
var resident = ToULong(DbScalarReader.GetInt64(reader, 3));
var residentAvailable = DbScalarReader.GetInt64(reader, 4) != 0;

if (group == SummaryMetricsTable.GroupTotals && category == SummaryMetricsTable.CategoryTotal)
{
metrics.TotalAllocatedBytes = committed;
metrics.TotalResidentBytes = resident;
}
else if (group == SummaryMetricsTable.GroupAllocatedMemoryDistribution)
{
metrics.AllocatedMemoryDistribution.Add(MakeCategory(category, committed, resident, residentAvailable));
}
else if (group == SummaryMetricsTable.GroupManagedHeapUtilization)
{
metrics.ManagedHeapUtilization.Add(MakeCategory(category, committed, resident, residentAvailable));
}
}

return (metrics, any);
}
catch (DbException)
{
// summary_metrics table absent (database exported by an older tool version).
return (metrics, false);
}
}

private static SummaryCategory MakeCategory(string name, ulong committed, ulong resident, bool residentAvailable) =>
new()
{
Name = name,
CommittedBytes = committed,
ResidentBytes = resident,
ResidentAvailable = residentAvailable,
};

private static ulong ToULong(long value) => value < 0 ? 0UL : (ulong)value;
}
Loading
Loading