Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,74 @@ public SwitchParameter UseMinimalHeader
get; set;
}

/// <summary>
/// Gets or sets whether to prefix each transcribed line of command
/// input and output with the time at which it was recorded to the
/// transcript, using the default format 'yyyy-MM-dd HH:mm:ss' or
/// the format specified by <see cref="TimeStampFormat"/>. Standalone
/// native command output on Windows is captured from the console
/// buffer only after the process exits, so all lines of such output
/// receive the completion time of the command. Piping native command
/// output into a PowerShell command (for example, 'native.exe |
/// Out-Host') records each line as it is produced, while redirecting
/// it directly to a file bypasses transcription.
/// </summary>
[Parameter]
public SwitchParameter IncludeTimeStamp
{
get; set;
}

/// <summary>
/// Gets or sets a .NET standard or custom date and time format string
/// used for the timestamp prefix on transcribed lines, for example
/// 'HH:mm:ss', 'o' or 'dddd, yyyy-MM-dd'. The default format is
/// 'yyyy-MM-dd HH:mm:ss'. Specifying this parameter enables timestamps,
/// so -IncludeTimeStamp does not need to be supplied as well.
/// </summary>
[Parameter]
[ValidateNotNullOrEmpty]
public string TimeStampFormat
{
get; set;
}

private static readonly char[] s_timeStampNewLineChars = { '\r', '\n' };

/// <summary>
/// Starts the transcription.
/// </summary>
protected override void BeginProcessing()
{
// Specifying a timestamp format implies -IncludeTimeStamp, so that the
// timestamps are enabled even when the switch itself is not supplied.
bool includeTimeStamp = IncludeTimeStamp.IsPresent || (TimeStampFormat != null);

// Validate the timestamp format before touching the file system so that
// an invalid format fails fast. Rendering a sample also catches format
// strings that would span multiple lines, which would corrupt the
// per-line transcript format.
if (TimeStampFormat != null)
{
string sampleTimeStamp = null;
try
{
sampleTimeStamp = DateTime.Now.ToString(
TimeStampFormat, System.Globalization.CultureInfo.InvariantCulture);
}
catch (FormatException)
{
}

if (sampleTimeStamp is null || sampleTimeStamp.IndexOfAny(s_timeStampNewLineChars) >= 0)
{
Exception inner = new FormatException(
StringUtil.Format(TranscriptStrings.InvalidTimeStampFormat, TimeStampFormat));
ThrowTerminatingError(new ErrorRecord(
inner, "InvalidTimeStampFormat", ErrorCategory.InvalidArgument, TimeStampFormat));
}
}

// If they haven't specified a path, figure out the correct output path.
if (!_isFilenameSet)
{
Expand Down Expand Up @@ -234,7 +297,7 @@ protected override void BeginProcessing()

System.Management.Automation.Remoting.PSSenderInfo psSenderInfo =
this.SessionState.PSVariable.GetValue("PSSenderInfo") as System.Management.Automation.Remoting.PSSenderInfo;
Host.UI.StartTranscribing(effectiveFilePath, psSenderInfo, IncludeInvocationHeader.ToBool(), UseMinimalHeader.IsPresent);
Host.UI.StartTranscribing(effectiveFilePath, psSenderInfo, IncludeInvocationHeader.ToBool(), UseMinimalHeader.IsPresent, includeTimeStamp, TimeStampFormat);

// ch.StartTranscribing(effectiveFilePath, Append);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,7 @@
<data name="ErrorStoppingTranscript" xml:space="preserve">
<value>An error occurred stopping transcription: {0}</value>
</data>
<data name="InvalidTimeStampFormat" xml:space="preserve">
<value>"{0}" is not a valid date and time format string for the TimeStampFormat parameter. See https://learn.microsoft.com/dotnet/standard/base-types/standard-date-and-time-format-strings and https://learn.microsoft.com/dotnet/standard/base-types/custom-date-and-time-format-strings for the supported format strings.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,14 @@ internal override void Complete()
bufferText.Append(lineContents.ToString().TrimEnd(Utils.Separators.SpaceOrTab));
}

// Screen-scraped output is only available after the process exits, and the
// console buffer does not track when each row was written. When transcript
// timestamps are enabled (Start-Transcript -IncludeTimeStamp), all lines of
// this block are therefore recorded with the completion time of the command.
// Per-line production times can only be captured when the native command
// output is piped into a PowerShell command (e.g. 'native.exe | Out-Host'),
// which streams each line through the pipeline as it is produced. Redirecting
// native output directly to a file bypasses transcription entirely.
this.Command.Context.InternalHost.UI.TranscribeResult(bufferText.ToString());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,11 @@ internal void TranscribeCommand(string commandText, InvocationInfo invocation)
transcript.OutputToLog.Add("**********************");
}

transcript.OutputToLog.Add(TranscriptionData.PromptText + commandText);
transcript.AddOutput(TranscriptionData.PromptText + commandText);
}
else
{
transcript.OutputToLog.Add(">> " + commandText);
transcript.AddOutput(">> " + commandText);
}
}
}
Expand Down Expand Up @@ -588,11 +588,13 @@ private void CheckSystemTranscript()
}
}

internal void StartTranscribing(string path, System.Management.Automation.Remoting.PSSenderInfo senderInfo, bool includeInvocationHeader, bool useMinimalHeader)
internal void StartTranscribing(string path, System.Management.Automation.Remoting.PSSenderInfo senderInfo, bool includeInvocationHeader, bool useMinimalHeader, bool includeTimeStamp, string timeStampFormat)
{
TranscriptionOption transcript = new TranscriptionOption();
transcript.Path = path;
transcript.IncludeInvocationHeader = includeInvocationHeader;
transcript.IncludeTimeStamp = includeTimeStamp;
transcript.TimeStampFormat = timeStampFormat;
TranscriptionData.Transcripts.Add(transcript);

LogTranscriptHeader(senderInfo, transcript, useMinimalHeader);
Expand Down Expand Up @@ -773,7 +775,7 @@ internal void TranscribeResult(Runspace sourceRunspace, string resultText)
{
lock (transcript.OutputToLog)
{
transcript.OutputToLog.Add(resultText);
transcript.AddOutput(resultText);
}
}
}
Expand Down Expand Up @@ -1239,6 +1241,51 @@ internal TranscriptionOption()
/// </summary>
internal bool IncludeInvocationHeader { get; set; }

/// <summary>
/// Whether to prefix each transcribed line of command input and
/// output with the time at which it was transcribed.
/// </summary>
internal bool IncludeTimeStamp { get; set; }

/// <summary>
/// The custom format used for the timestamp prefix. When null, the
/// default <see cref="DefaultTimeStampFormat"/> is used.
/// </summary>
internal string TimeStampFormat { get; set; }

/// <summary>
/// The default format used for the timestamp prefix, i.e. '2024-08-23 11:04:52 '.
/// </summary>
internal const string DefaultTimeStampFormat = "yyyy-MM-dd HH:mm:ss";

private static readonly string[] s_newLineSeparators = { "\r\n", "\n", "\r" };

/// <summary>
/// Adds output to be logged to this transcript. When <see cref="IncludeTimeStamp"/>
/// is set, each non-empty line of the output is prefixed with the current time.
/// </summary>
/// <param name="text">The text to be logged.</param>
internal void AddOutput(string text)
{
if (!IncludeTimeStamp || string.IsNullOrEmpty(text))
{
OutputToLog.Add(text);
return;
}

string timeStamp = DateTime.Now.ToString(TimeStampFormat ?? DefaultTimeStampFormat, CultureInfo.InvariantCulture);
string[] lines = text.Split(s_newLineSeparators, StringSplitOptions.None);
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].Length > 0)
{
lines[i] = string.Concat(timeStamp, " ", lines[i]);
}
}

OutputToLog.Add(string.Join(Environment.NewLine, lines));
}

/// <summary>
/// Logs buffered content to disk. We use this instead of File.AppendAllLines
/// so that we don't need to pay seek penalties all the time, and so that we
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ Describe "Start-Transcript, Stop-Transcript tests" -tags "CI" {
$transcriptFilePath | Should -FileContentMatch $machineName
}

It "Transcription should prefix native command output with a timestamp when 'IncludeTimeStamp' parameter is used" {
# The native command output is piped so that it is transcribed through the
# regular output stream. Standalone native command output is only captured by
# screen scraping when a physical console is available, which is not guaranteed
# in test environments.
$script = {
Start-Transcript -Path $transcriptFilePath -IncludeTimeStamp
hostname | ForEach-Object { Write-Host -Message $_ }
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
$machineName = [System.Environment]::MachineName
(Get-Content $transcriptFilePath | Where-Object { $_ -match "^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} .*$machineName" }).Count | Should -BeGreaterThan 0
}

It "Transcription should record Write-Information output when InformationAction is set to Continue" {
[String]$message = New-Guid
$script = {
Expand Down Expand Up @@ -321,4 +339,82 @@ Describe "Start-Transcript, Stop-Transcript tests" -tags "CI" {

$transcriptMinHeaderLength | Should -BeLessThan $transcriptLength
}

It "Transcription should prefix recorded output with a timestamp when 'IncludeTimeStamp' parameter is used" {
$message = "TimeStampTestMessage"
$script = {
Start-Transcript -Path $transcriptFilePath -IncludeTimeStamp
Write-Host -Message $message
Write-Host -Message "$message second`n$message third"
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
$content = Get-Content $transcriptFilePath
($content | Where-Object { $_ -match "^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} .*$message" }).Count | Should -Be 3
}

It "Transcription should not prefix recorded output with a timestamp by default" {
$message = "NoTimeStampTestMessage"
$script = {
Start-Transcript -Path $transcriptFilePath
Write-Host -Message $message
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
$transcriptFilePath | Should -FileContentMatch $message
(Get-Content $transcriptFilePath | Where-Object { $_ -match "^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} " }).Count | Should -Be 0
}

It "Transcription should use the format specified by the 'TimeStampFormat' parameter" {
$message = "CustomFormatTestMessage"
$script = {
Start-Transcript -Path $transcriptFilePath -IncludeTimeStamp -TimeStampFormat "HH:mm:ss"
Write-Host -Message $message
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
(Get-Content $transcriptFilePath | Where-Object { $_ -match "^\d{2}:\d{2}:\d{2} .*$message" }).Count | Should -BeGreaterThan 0
}

It "Specifying 'TimeStampFormat' alone should enable timestamps" {
$message = "FormatOnlyTestMessage"
$script = {
Start-Transcript -Path $transcriptFilePath -TimeStampFormat "yyyy/MM/dd"
Write-Host -Message $message
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
(Get-Content $transcriptFilePath | Where-Object { $_ -match "^\d{4}/\d{2}/\d{2} .*$message" }).Count | Should -BeGreaterThan 0
}

It "Transcription should support standard .NET date and time format strings for 'TimeStampFormat'" {
$message = "StandardFormatTestMessage"
$script = {
Start-Transcript -Path $transcriptFilePath -TimeStampFormat "s"
Write-Host -Message $message
Stop-Transcript
}

& $script

$transcriptFilePath | Should -Exist
(Get-Content $transcriptFilePath | Where-Object { $_ -match "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} .*$message" }).Count | Should -BeGreaterThan 0
}

It "Should reject an invalid 'TimeStampFormat'" {
{ Start-Transcript -Path $transcriptFilePath -IncludeTimeStamp -TimeStampFormat "yyyy 'unterminated" } | Should -Throw -ErrorId "InvalidTimeStampFormat,Microsoft.PowerShell.Commands.StartTranscriptCommand"
{ Start-Transcript -Path $transcriptFilePath -IncludeTimeStamp -TimeStampFormat "q" } | Should -Throw -ErrorId "InvalidTimeStampFormat,Microsoft.PowerShell.Commands.StartTranscriptCommand"
}
}