Skip to content

Commit 357c3c0

Browse files
authored
Trace pipeline output in a streaming way (#111)
In order to trace the pipeline output in a streaming way, the `DataAdding` event won't work, because even though you can get the pipeline object right after it's inserted into the pipeline, you will have to call `Out-String` to get the formatted output. You cannot call `Out-String` within the same Runspace as it's busying running the user's function script, so you will have to create a separate Runspace just for this purpose. This won't work given the concurrency support PS worker needs to have. The approach I choose is to have a special cmdlet `Trace-PipelineObject` behaves like `Tee-Object`. For each object from the pipeline, `Trace-PipelineObject` pushes the object through a `SteppablePipeline` that consists of `Out-String -Stream | Write-Information -Tags "__PipelineObject__"`, and then writes the object back to pipeline. By using this `SteppablePipeline`, every pipeline object is properly formatted and the resulted strings are written to the information stream.
1 parent 4593c43 commit 357c3c0

File tree

10 files changed

+121
-54
lines changed

10 files changed

+121
-54
lines changed

src/Logging/ILogger.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
namespace Microsoft.Azure.Functions.PowerShellWorker.Utility
1010
{
11-
internal abstract class ILogger
11+
internal interface ILogger
1212
{
13-
public abstract void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false);
13+
void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false);
1414
}
1515
}

src/Logging/RpcLogger.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void Dispose()
3939
_invocationId = null;
4040
}
4141

42-
public override async void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false)
42+
public async void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false)
4343
{
4444
if (isUserLog)
4545
{

src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ FormatsToProcess = @()
5151
NestedModules = @()
5252

5353
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
54-
FunctionsToExport = @('Push-OutputBinding', 'Get-OutputBinding')
54+
FunctionsToExport = @('Push-OutputBinding', 'Get-OutputBinding', 'Trace-PipelineObject')
5555

5656
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
5757
CmdletsToExport = @()

src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ using namespace Microsoft.Azure.Functions.PowerShellWorker
99

1010
# This holds the current state of the output bindings.
1111
$script:_OutputBindings = @{}
12+
# These variables hold the ScriptBlock and CmdletInfo objects for constructing a SteppablePipeline of 'Out-String | Write-Information'.
13+
$script:outStringCmd = $ExecutionContext.InvokeCommand.GetCommand("Microsoft.PowerShell.Utility\Out-String", [CommandTypes]::Cmdlet)
14+
$script:writeInfoCmd = $ExecutionContext.InvokeCommand.GetCommand("Microsoft.PowerShell.Utility\Write-Information", [CommandTypes]::Cmdlet)
15+
$script:tracingSb = { & $script:outStringCmd -Stream | & $script:writeInfoCmd -Tags "__PipelineObject__" }
1216
# This loads the resource strings.
1317
Import-LocalizedData LocalizedData -FileName PowerShellWorker.Resource.psd1
1418

@@ -183,3 +187,43 @@ function Push-OutputBinding {
183187
}
184188
}
185189
}
190+
191+
<#
192+
.SYNOPSIS
193+
Writes the formatted output of the pipeline object to the information stream before passing the object down to the pipeline.
194+
.DESCRIPTION
195+
INTERNAL POWERSHELL WORKER USE ONLY. Writes the formatted output of the pipeline object to the information stream before passing the object down to the pipeline.
196+
.PARAMETER InputObject
197+
The object from pipeline.
198+
#>
199+
function Trace-PipelineObject {
200+
201+
[CmdletBinding()]
202+
param(
203+
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
204+
[object]
205+
$InputObject
206+
)
207+
208+
<#
209+
This function behaves like 'Tee-Object'.
210+
An input pipeline object is first pushed through a steppable pipeline that consists of 'Out-String | Write-Information -Tags "__PipelineObject__"',
211+
and then it's written out back to the pipeline without change. In this approach, we can intercept and trace the pipeline objects in a streaming way
212+
and keep the objects in pipeline at the same time.
213+
#>
214+
215+
Begin {
216+
# A micro-optimization: we use the cached 'CmdletInfo' objects to avoid command resolution every time this cmdlet is called.
217+
$stepPipeline = $script:tracingSb.GetSteppablePipeline([CommandOrigin]::Internal)
218+
$stepPipeline.Begin($PSCmdlet)
219+
}
220+
221+
Process {
222+
$stepPipeline.Process($InputObject)
223+
$InputObject
224+
}
225+
226+
End {
227+
$stepPipeline.End()
228+
}
229+
}

src/PowerShell/PowerShellExtensions.cs

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,5 @@ public static Collection<T> InvokeAndClearCommands<T>(this PowerShell pwsh)
3737
pwsh.Commands.Clear();
3838
}
3939
}
40-
41-
public static string FormatObjectToString(this PowerShell pwsh, object inputObject)
42-
{
43-
// PowerShell's `Out-String -InputObject` handles collections differently
44-
// than when receiving InputObjects from the pipeline. (i.e. `$collection | Out-String`).
45-
// That is why we need `Write-Output` here. See related GitHub issue here:
46-
// https://github.com/PowerShell/PowerShell/issues/8246
47-
return pwsh.AddCommand("Write-Output")
48-
.AddParameter("InputObject", inputObject)
49-
.AddCommand("Out-String")
50-
.InvokeAndClearCommands<string>()[0];
51-
}
5240
}
5341
}

src/PowerShell/PowerShellManager.cs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal class PowerShellManager
2727
internal PowerShellManager(ILogger logger)
2828
{
2929
var initialSessionState = InitialSessionState.CreateDefault();
30-
30+
3131
// Setting the execution policy on macOS and Linux throws an exception so only update it on Windows
3232
if(Platform.IsWindows)
3333
{
@@ -161,30 +161,27 @@ internal Hashtable InvokeFunction(
161161
Collection<object> pipelineItems = null;
162162
using (ExecutionTimer.Start(_logger, "Execution of the user's function completed."))
163163
{
164-
pipelineItems = _pwsh.InvokeAndClearCommands<object>();
164+
pipelineItems = _pwsh.AddCommand("Microsoft.Azure.Functions.PowerShellWorker\\Trace-PipelineObject")
165+
.InvokeAndClearCommands<object>();
165166
}
166167

167168
var result = _pwsh.AddCommand("Microsoft.Azure.Functions.PowerShellWorker\\Get-OutputBinding")
168-
.AddParameter("Purge")
169+
.AddParameter("Purge", true)
169170
.InvokeAndClearCommands<Hashtable>()[0];
170171

172+
/*
173+
* TODO: See GitHub issue #82. We are not settled on how to handle the Azure Functions concept of the $returns Output Binding
171174
if (pipelineItems != null && pipelineItems.Count > 0)
172175
{
173-
// Log everything we received from the pipeline
174-
foreach (var item in pipelineItems)
175-
{
176-
_logger.Log(LogLevel.Information, $"OUTPUT: {_pwsh.FormatObjectToString(item)}", isUserLog: true);
177-
}
178-
179-
// TODO: See GitHub issue #82. We are not settled on how to handle the Azure Functions concept of the $returns Output Binding
180-
// If we would like to support Option 1 from #82, uncomment the following code:
181-
// object[] items = new object[pipelineItems.Count];
182-
// pipelineItems.CopyTo(items, 0);
183-
// result.Add(AzFunctionInfo.DollarReturn, items);
184-
185-
// If we would like to support Option 2 from #82, uncomment this line:
186-
// result.Add(AzFunctionInfo.DollarReturn, pipelineItems[pipelineItems.Count - 1]);
176+
// If we would like to support Option 1 from #82, use the following 3 lines of code:
177+
object[] items = new object[pipelineItems.Count];
178+
pipelineItems.CopyTo(items, 0);
179+
result.Add(AzFunctionInfo.DollarReturn, items);
180+
181+
// If we would like to support Option 2 from #82, use this line:
182+
result.Add(AzFunctionInfo.DollarReturn, pipelineItems[pipelineItems.Count - 1]);
187183
}
184+
*/
188185

189186
return result;
190187
}

src/PowerShell/StreamHandler.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public void InformationDataAdding(object sender, DataAddingEventArgs e)
3939
{
4040
if(e.ItemAdded is InformationRecord record)
4141
{
42-
_logger.Log(LogLevel.Information, $"INFORMATION: {record.MessageData}", isUserLog: true);
42+
string prefix = (record.Tags.Count == 1 && record.Tags[0] == "__PipelineObject__") ? "OUTPUT:" : "INFORMATION:";
43+
_logger.Log(LogLevel.Information, $"{prefix} {record.MessageData}", isUserLog: true);
4344
}
4445
}
4546

test/Unit/Logging/ConsoleLogger.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal class ConsoleLogger : ILogger
1515
{
1616
public List<string> FullLog = new List<string>();
1717

18-
public override void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false)
18+
public void Log(LogLevel logLevel, string message, Exception exception = null, bool isUserLog = false)
1919
{
2020
var log = $"{logLevel}: {message}";
2121
Console.WriteLine(log);

test/Unit/Modules/Microsoft.Azure.Functions.PowerShellWorker.Tests.ps1

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,60 @@ Describe 'Azure Functions PowerShell Langauge Worker Helper Module Tests' {
154154
-Because 'The OutputBindings should be empty'
155155
}
156156
}
157+
158+
Context 'Trace-PipelineObject tests' {
159+
BeforeAll {
160+
$scriptToRun = @'
161+
param($cmd, $modulePath)
162+
Import-Module $modulePath
163+
function Write-TestObject {
164+
foreach ($i in 1..20) {
165+
Write-Output $cmd
166+
}
167+
Write-Information '__LAST_INFO_MSG__'
168+
}
169+
'@
170+
$cmd = Get-Command Get-Command
171+
$ps = [powershell]::Create()
172+
$ps.AddScript($scriptToRun).AddParameter("cmd", $cmd).AddParameter("modulePath", $modulePath).Invoke()
173+
$ps.Commands.Clear()
174+
$ps.Streams.ClearStreams()
175+
176+
function Write-TestObject {
177+
foreach ($i in 1..20) {
178+
Write-Output $cmd
179+
}
180+
Write-Information '__LAST_INFO_MSG__'
181+
}
182+
}
183+
184+
AfterAll {
185+
$ps.Dispose()
186+
}
187+
188+
AfterEach {
189+
$ps.Commands.Clear()
190+
$ps.Streams.ClearStreams()
191+
}
192+
193+
It "Can write tracing to information stream while keeps input object in pipeline" {
194+
$results = $ps.AddCommand("Write-TestObject").AddCommand("Trace-PipelineObject").Invoke()
195+
196+
$results.Count | Should -BeExactly 20
197+
for ($i = 0; $i -lt 20; $i++) {
198+
$results[0].Name | Should -BeExactly $cmd.Name
199+
}
200+
201+
$outStringResults = Write-TestObject | Out-String -Stream
202+
$ps.Streams.Information.Count | Should -BeExactly ($outStringResults.Count + 1)
203+
$countWithoutTrailingNewLines = $outStringResults.Count - 2
204+
for ($i = 0; $i -lt $countWithoutTrailingNewLines; $i++) {
205+
$ps.Streams.Information[$i].MessageData | Should -BeExactly $outStringResults[$i]
206+
$ps.Streams.Information[$i].Tags | Should -BeExactly "__PipelineObject__"
207+
}
208+
209+
$ps.Streams.Information[$i].MessageData | Should -BeExactly "__LAST_INFO_MSG__"
210+
$ps.Streams.Information[$i].Tags | Should -BeNullOrEmpty
211+
}
212+
}
157213
}

test/Unit/PowerShell/PowerShellExtensionsTests.cs

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,5 @@ public void CanInvokeAndClearCommandsWithReturnValue()
3838

3939
Assert.Single(results, data);
4040
}
41-
42-
[Fact]
43-
public void CanFormatObjectsToString()
44-
{
45-
string[] expectedResult = {
46-
"",
47-
"Name Value",
48-
"---- -----",
49-
"Foo bar",
50-
"",
51-
"",
52-
""
53-
};
54-
55-
Hashtable data = new Hashtable {{ "Foo", "bar"}};
56-
Assert.Equal(
57-
string.Join(Environment.NewLine, expectedResult),
58-
PowerShell.Create().FormatObjectToString(data));
59-
}
6041
}
6142
}

0 commit comments

Comments
 (0)