Skip to content

Commit 3c13bae

Browse files
committed
feat: show log file path on benchmark finished
1 parent 31b9411 commit 3c13bae

File tree

6 files changed

+355
-2
lines changed

6 files changed

+355
-2
lines changed

src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
using BenchmarkDotNet.Toolchains.CoreRun;
2222
using BenchmarkDotNet.Toolchains.CsProj;
2323
using BenchmarkDotNet.Toolchains.DotNetCli;
24-
using BenchmarkDotNet.Toolchains.InProcess.Emit;
2524
using BenchmarkDotNet.Toolchains.MonoAotLLVM;
2625
using BenchmarkDotNet.Toolchains.MonoWasm;
2726
using BenchmarkDotNet.Toolchains.NativeAot;
@@ -730,6 +729,9 @@ private static IEnumerable<IFilter> GetFilters(CommandLineOptions options)
730729

731730
private static int GetMaximumDisplayWidth()
732731
{
732+
if (Console.IsOutputRedirected)
733+
return MinimumDisplayWidth;
734+
733735
try
734736
{
735737
return Console.WindowWidth;
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using BenchmarkDotNet.Detectors;
2+
using BenchmarkDotNet.Loggers;
3+
using System;
4+
using System.Diagnostics;
5+
using System.Runtime.InteropServices;
6+
using System.Runtime.Versioning;
7+
using System.Text.RegularExpressions;
8+
9+
namespace BenchmarkDotNet.Helpers;
10+
11+
#nullable enable
12+
13+
internal static class ConsoleHelper
14+
{
15+
private const string ESC = "\e"; // Escape sequence.
16+
private const string OSC8 = $"{ESC}]8;;"; // Operating System Command 8
17+
private const string ST = ESC + @"\"; // String Terminator
18+
19+
/// <summary>
20+
/// Write clickable link to console.
21+
/// If console doesn't support OSC 8 hyperlinks. It writes plain link with markdown syntax.
22+
/// </summary>
23+
public static void WriteLineAsClickableLink(ILogger consoleLogger, string link, string? linkCaption = null, LogKind logKind = LogKind.Info, string prefixText = "", string suffixText = "")
24+
{
25+
if (prefixText != "")
26+
consoleLogger.Write(logKind, prefixText);
27+
28+
WriteAsClickableLink(consoleLogger, link, linkCaption, logKind);
29+
30+
// On Windows Terminal environment.
31+
// It need to write extra space to avoid link style corrupted issue that occurred when window resized.
32+
if (IsWindowsTerminal.Value && IsClickableLinkSupported.Value && suffixText == "")
33+
suffixText = " ";
34+
35+
if (suffixText != "")
36+
consoleLogger.Write(logKind, suffixText);
37+
38+
consoleLogger.WriteLine();
39+
}
40+
41+
/// <summary>
42+
/// Write clickable link to console.
43+
/// If console doesn't support OSC 8 hyperlinks. It writes plain link with markdown syntax.
44+
/// </summary>
45+
public static void WriteAsClickableLink(ILogger consoleLogger, string link, string? linkCaption = null, LogKind logKind = LogKind.Info)
46+
{
47+
if (consoleLogger.Id != nameof(ConsoleLogger))
48+
throw new NotSupportedException("This method is expected logger that has ConsoleLogger id.");
49+
50+
// If clickable link supported. Write clickable link with OSC8.
51+
if (IsClickableLinkSupported.Value)
52+
{
53+
consoleLogger.Write(logKind, @$"{OSC8}{link}{ST}{linkCaption ?? link}{OSC8}{ST}");
54+
return;
55+
}
56+
57+
// If link caption is specified. Write link as plain text with markdown link syntax.
58+
if (!string.IsNullOrEmpty(linkCaption))
59+
{
60+
consoleLogger.Write(logKind, $"[{linkCaption}]({link})");
61+
return;
62+
}
63+
64+
// Write link as plain text.
65+
consoleLogger.Write(logKind, link);
66+
}
67+
68+
private static readonly Lazy<bool> IsWindowsTerminal = new(()
69+
=> Environment.GetEnvironmentVariable("WT_SESSION") != null);
70+
71+
private static readonly Lazy<bool> IsClickableLinkSupported = new(() =>
72+
{
73+
if (Console.IsOutputRedirected)
74+
return false;
75+
76+
// The current console doesn't have a valid buffer size, which means it is not a real console.
77+
if (Console.BufferHeight == 0 || Console.BufferWidth == 0)
78+
return false;
79+
80+
// Disable clickable link on CI environment.
81+
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")))
82+
return false;
83+
84+
// dumb terminal don't support ANSI escape sequence.
85+
var term = Environment.GetEnvironmentVariable("TERM") ?? "";
86+
if (term == "dumb")
87+
return false;
88+
89+
if (OsDetector.IsWindows())
90+
{
91+
try
92+
{
93+
// conhost.exe don't support clickable link with OSC8.
94+
if (IsRunningOnConhost())
95+
return false;
96+
97+
// ConEmu and don't support OSC8.
98+
var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI");
99+
if (conEmu != null)
100+
return false;
101+
102+
// Return true if Virtual Terminal Processing mode is enabled.
103+
return IsVirtualTerminalProcessingEnabled();
104+
}
105+
catch
106+
{
107+
return false; // Ignore unexpected exception.
108+
}
109+
}
110+
else
111+
{
112+
// screen don't support OSC8 clickable link.
113+
if (Regex.IsMatch(term, "^screen"))
114+
return false;
115+
116+
// Other major terminal supports OSC8 by default. https://github.com/Alhadis/OSC8-Adoption
117+
return true;
118+
}
119+
});
120+
121+
[SupportedOSPlatform("windows")]
122+
private static bool IsVirtualTerminalProcessingEnabled()
123+
{
124+
// Try to get Virtual Terminal Processing enebled or not.
125+
const uint STD_OUTPUT_HANDLE = unchecked((uint)-11);
126+
IntPtr handle = NativeMethods.GetStdHandle(STD_OUTPUT_HANDLE);
127+
if (handle == IntPtr.Zero)
128+
return false;
129+
130+
if (NativeMethods.GetConsoleMode(handle, out uint consoleMode))
131+
{
132+
const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
133+
if ((consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) > 0)
134+
{
135+
return true;
136+
}
137+
}
138+
return false;
139+
}
140+
141+
[SupportedOSPlatform("windows")]
142+
private static bool IsRunningOnConhost()
143+
{
144+
IntPtr hwnd = NativeMethods.GetConsoleWindow();
145+
if (hwnd == IntPtr.Zero)
146+
return false;
147+
148+
NativeMethods.GetWindowThreadProcessId(hwnd, out uint pid);
149+
using var process = Process.GetProcessById((int)pid);
150+
return process.ProcessName == "conhost";
151+
}
152+
153+
[SupportedOSPlatform("windows")]
154+
private static class NativeMethods
155+
{
156+
[DllImport("kernel32.dll", SetLastError = true)]
157+
public static extern IntPtr GetStdHandle(uint nStdHandle);
158+
159+
[DllImport("kernel32.dll", SetLastError = true)]
160+
public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
161+
162+
[DllImport("kernel32.dll", SetLastError = true)]
163+
public static extern IntPtr GetConsoleWindow();
164+
165+
[DllImport("user32.dll", SetLastError = true)]
166+
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
167+
}
168+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
5+
namespace BenchmarkDotNet.Helpers;
6+
7+
#nullable enable
8+
9+
internal static class PathHelper
10+
{
11+
public static string GetRelativePath(string relativeTo, string path)
12+
{
13+
#if !NETSTANDARD2_0
14+
return Path.GetRelativePath(relativeTo, path);
15+
#else
16+
return GetRelativePathCompat(relativeTo, path);
17+
#endif
18+
}
19+
20+
public static string GetRelativePathCompat(string relativeTo, string path)
21+
{
22+
// Get absolute full paths
23+
string basePath = Path.GetFullPath(relativeTo);
24+
string targetPath = Path.GetFullPath(path);
25+
26+
// Normalize base to directory (Path.GetRelativePath treats base as directory always)
27+
if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString()))
28+
basePath += Path.DirectorySeparatorChar;
29+
30+
// If roots differ, return the absolute target
31+
string baseRoot = Path.GetPathRoot(basePath)!;
32+
string targetRoot = Path.GetPathRoot(targetPath)!;
33+
if (!string.Equals(baseRoot, targetRoot, StringComparison.OrdinalIgnoreCase))
34+
return targetPath;
35+
36+
// Break into segments
37+
var baseSegments = SplitPath(basePath);
38+
var targetSegments = SplitPath(targetPath);
39+
40+
// Find common prefix
41+
int i = 0;
42+
while (i < baseSegments.Count && i < targetSegments.Count && string.Equals(baseSegments[i], targetSegments[i], StringComparison.OrdinalIgnoreCase))
43+
{
44+
i++;
45+
}
46+
47+
// Build relative parts
48+
var relativeParts = new List<string>();
49+
50+
// For each remaining segment in base -> go up one level
51+
for (int j = i; j < baseSegments.Count; j++)
52+
relativeParts.Add("..");
53+
54+
// For each remaining in target -> add those segments
55+
for (int j = i; j < targetSegments.Count; j++)
56+
relativeParts.Add(targetSegments[j]);
57+
58+
// If nothing added, it is the same directory
59+
if (relativeParts.Count == 0)
60+
return ".";
61+
62+
// Join with separator and return
63+
return string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts);
64+
}
65+
66+
private static List<string> SplitPath(string path)
67+
{
68+
var segments = new List<string>();
69+
string[] raw = path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
70+
71+
foreach (var seg in raw)
72+
{
73+
// Skip root parts like "C:\"
74+
if (seg.EndsWith(":"))
75+
continue;
76+
segments.Add(seg);
77+
}
78+
79+
return segments;
80+
}
81+
}

src/BenchmarkDotNet/Loggers/CompositeLogger.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
using System.Collections.Immutable;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Linq;
4+
5+
#nullable enable
26

37
namespace BenchmarkDotNet.Loggers
48
{
@@ -57,5 +61,14 @@ public void Flush()
5761
}
5862
}
5963
}
64+
65+
/// <summary>
66+
/// Try to gets logger that has id of ConsoleLogger.
67+
/// </summary>
68+
public bool TryGetConsoleLogger([NotNullWhen(true)] out ILogger? consoleLogger)
69+
{
70+
consoleLogger = loggers.FirstOrDefault(x => x.Id == nameof(ConsoleLogger));
71+
return consoleLogger != null;
72+
}
6073
}
6174
}

src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ internal static Summary[] Run(BenchmarkRunInfo[] benchmarkRunInfos)
173173
var totalTime = globalChronometer.GetElapsed().GetTimeSpan();
174174
int totalNumberOfExecutedBenchmarks = results.Sum(summary => summary.GetNumberOfExecutedBenchmarks());
175175
LogTotalTime(compositeLogger, totalTime, totalNumberOfExecutedBenchmarks, "Global total time");
176+
compositeLogger.WriteLine();
176177

177178
return results.ToArray();
178179
}
@@ -191,6 +192,20 @@ internal static Summary[] Run(BenchmarkRunInfo[] benchmarkRunInfos)
191192
compositeLogger.WriteLineInfo("Artifacts cleanup is finished");
192193
compositeLogger.Flush();
193194

195+
// Output additional information to console.
196+
var logFileEnabled = benchmarkRunInfos.All(info => !info.Config.Options.IsSet(ConfigOptions.DisableLogFile));
197+
if (logFileEnabled && compositeLogger.TryGetConsoleLogger(out var consoleLogger))
198+
{
199+
var artifactDirectoryFullPath = Path.GetFullPath(rootArtifactsFolderPath);
200+
var logFileFullPath = Path.GetFullPath(logFilePath);
201+
var logFileRelativePath = PathHelper.GetRelativePath(artifactDirectoryFullPath, logFileFullPath);
202+
203+
consoleLogger.WriteLine();
204+
consoleLogger.WriteLineHeader("// * Benchmark LogFile *");
205+
ConsoleHelper.WriteLineAsClickableLink(consoleLogger, artifactDirectoryFullPath);
206+
ConsoleHelper.WriteLineAsClickableLink(consoleLogger, logFileFullPath, linkCaption: logFileRelativePath, prefixText: " ");
207+
}
208+
194209
eventProcessor.OnEndRunStage();
195210
}
196211
}
@@ -752,7 +767,7 @@ private static StreamWriter GetLogFileStreamWriter(BenchmarkRunInfo[] benchmarkR
752767
return new StreamWriter(logFilePath, append: false);
753768
}
754769

755-
private static ILogger CreateCompositeLogger(BenchmarkRunInfo[] benchmarkRunInfos, StreamLogger streamLogger)
770+
private static CompositeLogger CreateCompositeLogger(BenchmarkRunInfo[] benchmarkRunInfos, StreamLogger streamLogger)
756771
{
757772
var loggers = new Dictionary<string, ILogger>();
758773

0 commit comments

Comments
 (0)