|
#requires -version 7 |
|
#You can load this script with $(iwr https://tinyurl.com/TraceAICommand | iex) |
|
using namespace Microsoft.ApplicationInsights |
|
using namespace Microsoft.ApplicationInsights.Extensibility |
|
using namespace Microsoft.ApplicationInsights.DataContracts |
|
using namespace System.Management.Automation |
|
using namespace System.Collections.Generic |
|
using namespace System.Net |
|
|
|
#Reference: https://docs.microsoft.com/en-us/azure/azure-monitor/app/console |
|
#Reference: https://docs.microsoft.com/en-us/azure/azure-monitor/app/custom-operations-tracking |
|
|
|
function Trace-AICommand { |
|
[CmdletBinding()] |
|
param( |
|
#The scriptblock to execute. It will run in the current scope. |
|
[Parameter(Mandatory, ValueFromPipeline)][ScriptBlock]$ScriptBlock, |
|
#A label for this operation to more easily identify it in the telemetry |
|
[ValidateNotNullOrEmpty()][String]$Name = 'PowerShell ScriptBlock', |
|
#Specify where this request is originating from. Defaults to the current hostname but you may want to define something custom like 'AzureAutomation' |
|
[String]$InstanceName = [Environment]::MachineName, |
|
#Specify the app name which will represent an individual instance in the app map. By default we use the $PSScriptRoot if this is a script and 'Powershell ScriptBlock' otherwise. |
|
[ValidateNotNullOrEmpty()][String]$RoleName = $MyInvocation.ScriptName ? $(Split-Path $MyInvocation.ScriptName -Leaf) : 'PowerShell ScriptBlock', |
|
#By default we log nonterminating errors as exceptions (but requests can still show OK). Set this if you want them tracked as traces instead. |
|
[Switch]$ErrorAsTrace, |
|
#By default we flush after each invocation but don't wait for it to complete. You will want to specify this if you want to ensure your telemetry uploads after the scriptblock before moving on. |
|
[Switch]$Wait, |
|
#Specify the connection string for your app insights instance. You can also specify this in the environment variable APPLICATIONINSIGHTS_CONNECTION_STRING |
|
[String]$ConnectionString = $(Get-Content ENV:APPLICATIONINSIGHTS_CONNECTION_STRING -ErrorAction SilentlyContinue), |
|
#By Default we don't log the script output however you can enable this. Be very careful with this setting, you can incur a lot of cost logging large outputs. |
|
[Switch]$LogOutput, |
|
#Specify if you would like the output to be serialized to JSON first before being sent to telemetry |
|
[Switch]$OutputAsJson, |
|
#Specify how many properties deep you would like to check. |
|
[int]$OutputAsJsonDepth = 3, |
|
#Specify an operation ID for a parent operation. This will be automatically recursively referenced if Trace-AICommand is referenced within itself |
|
[string]$ParentId = $__TRACEAICOMMAND_CURRENTID, |
|
#If you are supplying a parentId for an external process correlation (e.g. microservices call), specify this flag as well. You don't normally need to do this. |
|
[string]$NoDependency, |
|
#Send telemetry as fast as possible. Only use for testing, this is not good for performance. |
|
[Switch]$DeveloperMode, |
|
[SeverityLevel]$WarningAs = [SeverityLevel]::Warning, |
|
[SeverityLevel]$ErrorAs = [SeverityLevel]::Error, |
|
[SeverityLevel]$VerboseAs = [SeverityLevel]::Verbose, |
|
[SeverityLevel]$DebugAs = [SeverityLevel]::Verbose, |
|
[SeverityLevel]$InformationAs = [SeverityLevel]::Information |
|
) |
|
|
|
#BUG: ValidateNotNullOrEmpty doesnt apply if a default value might be null so we have to do this extra check |
|
if ([string]::IsNullOrEmpty($connectionString)) { throw 'You must specify a connection string or set the environment variable APPLICATIONINSIGHTS_CONNECTION_STRING' } |
|
|
|
#Instantiate a common telemetry client. This is the rare case GLOBAL makes sense as we want it to be process-level. |
|
$GLOBAL:__AIClient ??= [Dictionary[guid, TelemetryClient]]@{} |
|
|
|
if (-not $connectionString) { |
|
Write-Warning 'You must specify a connection string for your app insights instance either via the ConnectionString parameter or by setting the environment variable APPLICATIONINSIGHTS_CONNECTION_STRING. The scriptblock will now be run without telemetry' |
|
& $ScriptBlock |
|
return |
|
} |
|
[guid]$InstrumentationKey = $connectionString -replace 'InstrumentationKey=([\w-]+)?;.+', '$1' |
|
if (-not $GLOBAL:__AIClient.ContainsKey($InstrumentationKey)) { |
|
$config = [TelemetryConfiguration]::CreateDefault() |
|
$config.ConnectionString = $connectionString |
|
$GLOBAL:__AIClient[$InstrumentationKey] = [TelemetryClient]::New($config) |
|
} |
|
[TelemetryClient]$client = $GLOBAL:__AIClient[$InstrumentationKey] |
|
if ($DeveloperMode) { $client.TelemetryConfiguration.TelemetryChannel.DeveloperMode = $true } |
|
|
|
$client.Context.Cloud.RoleInstance = $InstanceName |
|
$client.Context.Cloud.RoleName = $RoleName |
|
#This should expire when this goes out of scope and allows for recursive operation dependencies |
|
|
|
#If we have a parentId and detect this is nested, a dependency item needs to be linked between our request and the parent to draw the |
|
#Lines in application map. This will be disposed once the current activity is completed. |
|
if ($ParentId -and $__TRACEAICOMMAND_CURRENTROLENAME -and $__TRACEAICOMMAND_CURRENTROLEINSTANCE) { |
|
[IOperationHolder[DependencyTelemetry]]$LOCAL:dependency = Start-AIDependency -Client $client -OperationName 'ChildScriptBlock' -ParentId $ParentId |
|
# $dependency.Telemetry.Type = 'script' |
|
$context = $LOCAL:dependency.Telemetry.Context |
|
$context.Cloud.RoleInstance = $__TRACEAICOMMAND_CURRENTROLEINSTANCE |
|
$context.Cloud.RoleName = $__TRACEAICOMMAND_CURRENTROLENAME |
|
#By replacing this, our Start-AIOperation will chain to the dependency link, not the original request |
|
$ParentId = $LOCAL:dependency.Telemetry.Id |
|
} |
|
|
|
[IOperationHolder[RequestTelemetry]]$operation = Start-AIOperation -Client $client -OperationName $Name -ParentId $ParentId |
|
# 'Tracking AppInsights Operation {0} OpId:{1} ParentId: {2}' -f $Name, $operation.Telemetry.Id, $operation.Telemetry.Context.Operation.ParentId | Write-Debug |
|
[string]$LOCAL:__TRACEAICOMMAND_CURRENTID = $operation.Telemetry.Id |
|
[string]$LOCAL:__TRACEAICOMMAND_CURRENTROLENAME = $operation.Telemetry.Context.Cloud.RoleName |
|
[string]$LOCAL:__TRACEAICOMMAND_CURRENTROLEINSTANCE = $operation.Telemetry.Context.Cloud.RoleInstance |
|
|
|
|
|
[bool]$isFailed = $false |
|
try { |
|
& $ScriptBlock *>&1 | ForEach-Object { |
|
$object = $PSItem |
|
|
|
#Should only happen once per object at its deepest scope |
|
Write-AITelemetry $object |
|
|
|
#Child Operations should pass thru on output stream |
|
if ($ParentId) { |
|
return $object |
|
} |
|
|
|
#The parent operation re-emits the object on the appropriate stream |
|
if ($object.__TRACEAICOMMAND_PROCESSED) { |
|
[void]$object.PSObject.Properties.Remove('__TRACEAICOMMAND_PROCESSED') |
|
} |
|
switch ($object.GetType().Name) { |
|
'DebugRecord' { $PSCmdlet.WriteDebug($object) } |
|
'VerboseRecord' { $PSCmdlet.WriteVerbose($object) } |
|
'InformationRecord' { $PSCmdlet.WriteInformation($object) } |
|
'WarningRecord' { $PSCmdlet.WriteWarning($object) } |
|
'ErrorRecord' { $PSCmdlet.WriteError($object) } |
|
default { $PSCmdlet.WriteObject($object) } |
|
} |
|
} |
|
} catch { |
|
if (-not $PSItem.Exception.Data['TraceIACommandProcessed']) { |
|
$client.TrackException( |
|
$PSItem.Exception, |
|
(Get-AIErrorRecordCustomProperties $PSItem) |
|
) |
|
#This should prevent further throws up the chain from being logged |
|
$psitem.exception.data['TraceIACommandProcessed'] = $true |
|
} |
|
$isFailed = $true |
|
throw $PSItem |
|
} finally { |
|
Stop-AIOperation -Client $client -Operation $operation -Failed:$isFailed |
|
if ($LOCAL:dependency) { |
|
$LOCAL:dependency.Dispose() |
|
} |
|
|
|
if ($Wait) { |
|
$client.Flush() |
|
} else { |
|
[void]$client.FlushAsync([System.Threading.CancellationTokenSource]::new().Token) |
|
} |
|
} |
|
} |
|
|
|
function Start-AIOperation ([TelemetryClient]$client, [String]$ParentId, [String]$OperationName, [String]$OperationId) { |
|
#TODO: In 7.3 we can call this generic method more succinctly |
|
$startOperationMethod = [TelemetryClientExtensions].GetMethod( |
|
'StartOperation', |
|
[type[]]@([telemetryclient], [string], [string], [string])).MakeGenericMethod([RequestTelemetry] |
|
) |
|
$operation = [IOperationHolder[RequestTelemetry]]$startOperationMethod.Invoke([TelemetryClientExtensions], @($client, $OperationName, $OperationId, $ParentId)) |
|
return $operation |
|
} |
|
|
|
function Start-AIDependency ([TelemetryClient]$client, [String]$ParentId, [String]$OperationName, [String]$OperationId) { |
|
[OutputType([IOperationHolder[DependencyTelemetry]])] |
|
$startDependency = [TelemetryClientExtensions].GetMethod( |
|
'StartOperation', |
|
[type[]]@( |
|
[telemetryclient], [string], [string], [string] |
|
)).MakeGenericMethod([DependencyTelemetry]) |
|
[IOperationHolder[DependencyTelemetry]]$startDependency.Invoke( |
|
[TelemetryClientExtensions], |
|
@( |
|
$client, |
|
$OperationName, |
|
$OperationId, |
|
$ParentId |
|
) |
|
) |
|
} |
|
|
|
function Stop-AIOperation ([TelemetryClient]$client, [IOperationHolder[RequestTelemetry]]$Operation, [Switch]$Failed) { |
|
$Operation.Telemetry.Success = -not $Failed |
|
$Operation.Telemetry.ResponseCode = $Failed ? 'TerminatingError' : 'OK' |
|
$Operation.Dispose() |
|
} |
|
|
|
function Get-AIErrorRecordCustomProperties ([ErrorRecord]$record) { |
|
[OutputType([Dictionary[String, String]])] |
|
[Dictionary[String, String]]$customProperties = @{} |
|
$customProperties.TargetObject = $record.TargetObject |
|
$customProperties.FullyQualifiedErrorId = $record.FullyQualifiedErrorId |
|
$customProperties.PositionMessage = $record.InvocationInfo.PositionMessage |
|
$customProperties.Category = $record.CategoryInfo.Category |
|
$customProperties.Reason = $record.CategoryInfo.Reason |
|
$customProperties.ScriptStackTrace = $record.ScriptStackTrace |
|
return $customProperties |
|
} |
|
|
|
function Write-AITelemetry ($object) { |
|
<# |
|
.SYNOPSIS |
|
Does the heavy lifting of sending the telemetry to appinsights, and will flag the object as processed so it only happens once per object. |
|
#> |
|
if ($object.__TRACEAICOMMAND_PROCESSED) { |
|
return |
|
} |
|
switch ($object.GetType().Name) { |
|
'VerboseRecord' { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
$client.TrackTrace( |
|
$object.Message, |
|
$VerboseAs |
|
) |
|
} |
|
} |
|
'DebugRecord' { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
[Dictionary[String, String]]$customProperties = @{} |
|
$customProperties.Severity = 'Debug' |
|
$client.TrackTrace( |
|
$object, |
|
$DebugAs, |
|
$customProperties |
|
) |
|
} |
|
|
|
} |
|
'WarningRecord' { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
$client.TrackTrace( |
|
$object, |
|
$WarningAs |
|
) |
|
} |
|
} |
|
'InformationRecord' { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
[InformationRecord]$record = $object |
|
[Dictionary[String, String]]$customProperties = @{} |
|
foreach ($tag in $record.Tags) { |
|
$customProperties["TAG:$tag"] = 'true' |
|
} |
|
$client.TrackTrace( |
|
$record.MessageData, |
|
$InformationAs, |
|
$customProperties |
|
) |
|
} |
|
} |
|
'ErrorRecord' { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
$err = [ErrorRecord]$object |
|
if ($ErrorAsTrace) { |
|
$client.TrackTrace( |
|
('{0}: {1}' -f $err.InvocationInfo.MyCommand, $err.ToString()), |
|
$ErrorAs, |
|
(Get-AIErrorRecordCustomProperties $err) |
|
) |
|
} else { |
|
$client.TrackException( |
|
$err.Exception, |
|
(Get-AIErrorRecordCustomProperties $err) |
|
) |
|
} |
|
} |
|
|
|
$PSCmdlet.WriteError($err) |
|
} |
|
default { |
|
if (-not $__TRACEAICOMMAND_NOTRACE) { |
|
if ($LogOutput) { |
|
if ($PSStyle.OutputRendering) { |
|
$currentStyle = $PSStyle.OutputRendering |
|
} |
|
[string]$objectAsString = try { |
|
if ($OutputAsJson) { |
|
$object | ConvertTo-Json -Depth $OutputAsJsonDepth -WarningAction silentlycontinue |
|
} else { |
|
$PSStyle.OutputRendering = 'PlainText' |
|
$object | Out-String |
|
} |
|
} finally { |
|
if (-not $OutputAsJson) { |
|
$PSStyle.OutputRendering = $currentStyle |
|
} |
|
} |
|
[Dictionary[String, String]]$customProperties = @{} |
|
$customProperties.Severity = 'Output' |
|
$client.TrackTrace( |
|
('OUTPUT: ' + $objectAsString), |
|
[SeverityLevel]::Information, |
|
$customProperties |
|
) |
|
} |
|
} |
|
} |
|
} |
|
|
|
#Use ETS to flag the object as processed so parents do not duplicate the telemetry processing |
|
$object | Add-Member -NotePropertyName __TRACEAICOMMAND_PROCESSED -NotePropertyValue $true |
|
} |