AiLogging.psm1

Param
(
    [Parameter(Mandatory=$false,Position=0)]
    [string]
        #Instrumentation key for AppInsights instance to log to
    $InstrumentationKey,
    [Parameter(Mandatory=$false,Position=1)]
    [string]
        #Name of the application that is producing the logs
        #For automation accounts, it can be name of automation account
        #Is automatically registered to metadata sent with every piece data logged
    $Application,
    [Parameter(Mandatory=$false,Position=2)]
    [string]
        #Name of the component producing the logs
        #For automation accounts, it can be name of the runbook
        #Is automatically registered to metadata sent with every piece data logged
        $Component,
    [Parameter(Mandatory=$false,Position=3)]
    [string]
    $Role,
        #Identifier of role sending the data
        #For automation accounts, it can be useful when runbook is running on more hosts, e.g. in case of hybrid workers
    [Parameter(Mandatory=$false,Position=4)]
    [string]
        #Identifier of instance sending the data
        #For automation accounts, it may be usefult for runbooks that run in multiple instances on the same hosts, e.g. runbooks with multiple configurations
    $Instance
)

#region Metadata manipulation
function Add-AiMetadata {
    <#
    .SYNOPSIS
        Registers additional metadata to be sent with all telemetry produced after registered - until removed by call of Remove-AiMetadata or Reset-AiMetadata
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]
            #Name of metadata to be registered. Will become custom dimension of telemetry data
            #Allows for easy searching/filtering in ApplicationInsights logs
        $Name,
        [Parameter(Mandatory)]
        [string]
            #Value of metadata
        $Value
    )
    
    process {
        if(-not (IsInitialized)) {return}
        if($Name -in $script:ProtectedMetadata) {throw (new-object System.ArgumentException("Dimension $Name cannot be overwritten",'Name'))}

        $script:telemetryMetadata[$Name]=$Value
    }
}

function Remove-AiMetadata {
    <#
    .SYNOPSIS
        Unregisters additional metadata previously registered via Add-AiMetadata
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]
            #Name of metadata to be unregistered
        $Name
    )
    
    process {
        if(-not (IsInitialized)) {return}
        if($Name -in $script:ProtectedMetadata) {throw (new-object System.ArgumentException("Dimension $Name cannot be removed",'Name'))}

        $script:telemetryMetadata.Remove($Name) | Out-Null
    }
}

function Reset-AiMetadata
{
    <#
    .SYNOPSIS
        Unregisters all additional metadata previously registered.
    #>

    process {
        if(-not (IsInitialized)) {return}
        $keys=@()
        foreach($key in $script:telemetryMetadata.Keys) {$keys+=$key}
        foreach($key in $Keys) {
            if($key -notin $script:ProtectedMetadata) {
                $script:telemetryMetadata.Remove($key) | Out-Null
            }
        }
    }
}
#endregion

Function Initialize-AiLogger
{
    <#
.SYNOPSIS
    Initializes logging infrastructure
 
.DESCRIPTION
    This command must be called before any other commands from module. It creates necessary structures for logging and connects toApplication Insights instance identified by intrumentation key.
    Command also registers custom dimensions to be sent with all data; dimensions are Application and Components named passed as parameter. This helps easy filter logs related to different apps and components stored in single AppInsights instance.
    Command also registers metric namespace for logging of standalone metrics via Write-AiMetric command.
 
.EXAMPLE
    Initialize-AiLogger -InstrumentationKey '9ccdf7c4-7dcb-4659-87b8-639126191720' -Application MyApp -Component 'MyAppComponent'
 
    #>

    Param
    (
        [Parameter(Mandatory)]
        [string]
            #AppInsights instrumentation key
        $InstrumentationKey,
        [Parameter(Mandatory)]
        [string]
            #Name of the application that is producing the logs
            #For automation accounts, it can be name of automation account
            #Is automatically registered to metadata sent with every piece data logged
        $Application,
        [Parameter(Mandatory)]
        [string]
            #Name of the component producing the logs
            #For automation accounts, it can be name of the runbook
            #Is automatically registered to metadata sent with every piece data logged
        $Component,
        [Parameter()]
        [string]
            #Identifier of role sending the data
            #For automation accounts, it can be useful when runbook is running on more hosts, e.g. in case of hybrid workers
        $Role,
        [Parameter()]
        [string]
            #Identifier of instance sending the data
            #For automation accounts, it may be usefult for runbooks that run in multiple instances on the same hosts, e.g. runbooks with multiple configurations
        $Instance
    )
    
    process
    {
        $script:telemetryClient=new-object Microsoft.ApplicationInsights.TelemetryClient
        $script:telemetryClient.InstrumentationKey = $InstrumentationKey
        $script:telemetryMetadata = New-Object 'System.Collections.Generic.Dictionary[String,String]'
        $script:telemetryMetadata['Application']=$Application
        $script:ProtectedMetadata+='Application'
        $script:telemetryMetadata['Component']=$Component
        $script:ProtectedMetadata+='Component'
        $script:MetricNamespace = "$Application`.$Component"
        if(-not [string]::IsNullOrEmpty($Role))
        {
            $script:TelemetryClient.Context.Cloud.RoleName=$Role
        }
        else {
            $script:TelemetryClient.Context.Cloud.RoleName = "$Application`.$Component"
        }    
        if(-not [string]::IsNullOrEmpty($Instance)) {
            $script:TelemetryClient.Context.Cloud.RoleInstance = $Instance
        }
    }
}

Function Write-AiTrace
{
    <#
    .SYNOPSIS
        Writes trace message with severity and optional custom metadata.
        Default severity level is Verbose
     
    .EXAMPLE
        Write-AiTrace 'Beginning processing'
 
    .EXAMPLE
        $meta = New-AiMetadata
        $meta['Context']='MyContext'
        Write-AiTrace -Message "Performed context-specific action" -AdditionalMetadata $meta
    #>

    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
            #Message to be traced
        $Message,
        [Parameter()]
        [Microsoft.ApplicationInsights.DataContracts.SeverityLevel]
            #Severity of message sent
        $Severity='Verbose',
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,String]]
            #Optional metadata to be sent with trace
        $Metadata=$null
    )
    Process
    {
        if(-not (IsInitialized)) {return}
        $data = new-object Microsoft.ApplicationInsights.DataContracts.TraceTelemetry($Message, $Severity)
        if($null -ne $Metadata) {
            foreach($key in $Metadata.Keys) {$data.Properties[$Key] = $Metadata[$key]}
        }
        foreach($key in $script:telemetryMetadata.Keys) {$data.Properties[$Key] = $script:telemetryMetadata[$key]}

        $script:telemetryClient.TrackTrace($data)
    }
}

Function Write-AiException
{
    <#
    .SYNOPSIS
        Traces exception along with optional custom metadata and metrics
    #>

    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Exception]
            #Exception to be traced
        $Exception,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,Double]]
            #Optional metrics to be sent with exception
        $Metrics=$null,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,String]]
            #Optional metadata to be sent with exception
        $Metadata=$null
    )
    Process
    {
        if(-not (IsInitialized)) {return}
        
        $data = new-object Microsoft.ApplicationInsights.DataContracts.ExceptionTelemetry($Exception)
        if($null -ne $Metadata) {
            foreach($key in $Metadata.Keys) {$data.Properties[$Key] = $Metadata[$key]}
        }
        if($null -ne $Metrics) {
            foreach($key in $Metrics.Keys) {$data.Metrics[$Key] = $Metrics[$key]}
        }
        foreach($key in $script:telemetryMetadata.Keys) {$data.Properties[$Key] = $script:telemetryMetadata[$key]}
        $script:telemetryClient.TrackException($data)
    }
}

Function Write-AiMetric
{
    <#
    .SYNOPSIS
        Logs metric value without any aggregation along with optional custom metadata
    #>

    param (
        [Parameter(Mandatory)]
        [string]
            #Name of the metric
        $Name,
        [Parameter(Mandatory)]
        [double]
            #Value of the metric
        $Value,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,String]]
            #Optional metadata to be sent with metric
        $Metadata=$null
    )
    Process
    {
        if(-not (IsInitialized)) {return}

        $data = new-object Microsoft.ApplicationInsights.DataContracts.MetricTelemetry($Name, $Value)
        $data.MetricNamespace = $script:MetricNamespace

        if($null -ne $Metadata) {
            foreach($key in $Metadata.Keys) {$data.Properties[$Key] = $Metadata[$key]}
        }
        foreach($key in $script:telemetryMetadata.Keys) {$data.Properties[$Key] = $script:telemetryMetadata[$key]}

        $script:telemetryClient.TrackMetric($data)
    }
}

function Write-AiDependency
{
    <#
    .SYNOPSIS
        Logs a call to external service
        Logged call is used by Application Insights to populate Application Map blade
    #>

    param
    (
        [Parameter(Mandatory)]
        [string]
            #Name of endpoint (hostname or host identifier for server being called)
        $Target,
        [Parameter(Mandatory)]
        [string]
            #Name of the dependency type, such as FTP, SQL or HTTP
        $TypeName,
        [Parameter(Mandatory)]
        [string]
            #Name of the dependency, such as FileDownload, StoredProcedureCall
        $Name,
        [Parameter()]
        [string]
            #Dependency details, such as name of SQL stored procedure, or complete URL requested via HTTP
        $Data,
        [Parameter(Mandatory)]
        [DateTime]
            #When the call started
        $Start,
        [Parameter(Mandatory)]
        [TimeSpan]
            #Duration of the call
        $Duration,
        [Parameter()]
        [string]
            #Result code returned by the service
        $ResultCode=$null,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,Double]]
            #Optional metrics to be sent with the dependency data
        $Metrics=$null,
        [Parameter()]
        [bool]
            #Whether or not call was successful
        $Success = $true
    )

    process
    {
        $dependencyData = new-object Microsoft.ApplicationInsights.DataContracts.DependencyTelemetry
        foreach($key in $script:telemetryMetadata.Keys) {$dependencyData.Properties[$Key] = $script:telemetryMetadata[$key]}
        $dependencyData.Type=$TypeName
        $dependencyData.Name=$Name
        $dependencyData.Timestamp=$Start
        $dependencyData.Duration = $Duration
        $dependencyData.Success = $Success
        $dependencyData.Target = $Target
        $dependencyData.Data = $Data
        if($null -ne $ResultCode) {
            $dependencyData.ResultCode = $ResultCode
        }
        if($null -ne $Metrics) {
            foreach($key in $Metrics.Keys) {
                $dependencyData.Metrics[$Key] = $Metrics[$key]
            }
        }

        $script:telemetryClient.TrackDependency($dependencyData)
    }
}

Function Write-AiEvent
{
    <#
    .SYNOPSIS
        Traces event along with optional custom metadata and metrics.
        Usable when logging additional metrics associated with/related to the event that is not suitable to be logged standalone
    #>

    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
            #Event to be traced
        $EventName,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,Double]]
            #Optional metrics to be sent with the event
        $Metrics=$null,
        [Parameter()]
        [System.Collections.Generic.Dictionary[String,String]]
            #Optional metadata to be sent with the event
        $Metadata=$null
    )
    Process
    {
        if(-not (IsInitialized)) {return}

        $data = new-object Microsoft.ApplicationInsights.DataContracts.EventTelemetry($EventName)

        if($null -ne $Metadata) {
            foreach($key in $Metadata.Keys) {$data.Properties[$Key] = $Metadata[$key]}
        }
        if($null -ne $Metrics) {
            foreach($key in $Metrics.Keys) {$data.Metrics[$Key] = $Metrics[$key]}
        }
        foreach($key in $script:telemetryMetadata.Keys) {$data.Properties[$Key] = $script:telemetryMetadata[$key]}
        $script:telemetryClient.TrackEvent($data)
    }
}

Function New-AiMetric
{
    <#
    .SYNOPSIS
        Creates Dictionary<String,Double> suitable for adding custom metric values and then sending with Events or Exceptions as Metrics parameter
        Not suitable for sending standalone metrics - use Write-AiMetric instead
    #>

    Process
    {
        new-object 'System.Collections.Generic.Dictionary[String,Double]'
    }
}

Function New-AiMetadata
{
    <#
    .SYNOPSIS
        Creates Dictionary<String,String> suitable for adding custom metadata and then sending with Events, Traces, Metrics or Exceptions as Metadata parameter
    #>

    Process
    {
        new-object 'System.Collections.Generic.Dictionary[String,String]'
    }
}

Function Set-AiOperationContext
{
    <#
    .SYNOPSIS
        Registers operation ID and name to be sent with all telemetry produced after registered - until this command is called without parameters, which unregisters operation ID and name.
        Use ParentId when interested in anylysis of logged chained operations
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
            #Identifier of running operation
        $Id,
        [Parameter()]
        [string]
            #Name of running operation
        $Name,
        [Parameter()]
        [string]
            #Name of parent operation (if any)
            #Useful for logging of chained operations
        $ParentId
    )

    Process
    {
        $script:telemetryClient.Context.Operation.Id = $Id
        $script:telemetryClient.Context.Operation.Name = $Name
        $script:telemetryClient.Context.Operation.ParentId = $ParentId
    }
}

Function Set-AiUserContext
{
    <#
    .SYNOPSIS
        Registers user ID and name to be sent with all telemetry produced after registered - until this command is called without parameters, which unregisters user ID and name.
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
            #Identifier of user involved in the operation
        $Id,
        [Parameter()]
        [string]
            #Name of user involved in the operation
        $Name
    )

    Process
    {
        $script:telemetryClient.Context.User.Id = $Id
        $script:telemetryClient.Context.User.AccountId = $Name
    }
}

#region Helpers
function IsInitialized
{
    process
    {
        if($null -eq $script:telemetryClient)
        {
            Write-Warning $MyInvocation.MyCommand.Module.PrivateData['Messages']['NotInitialized'] -Verbose
            return $false
        }
        return $true
    }
}
#endregion


#region ImplicitInitialization for arguments passed to Import-Module
$script:ProtectedMetadata = @()

if(-not ([string]::IsNullOrEmpty($InstrumentationKey) -or [string]::IsNullOrEmpty($Application) -or [string]::IsNullOrEmpty($Component)))
{
    Initialize-AiLogger -InstrumentationKey $InstrumentationKey -Application $Application -Component $Component -Role $Role -Instance $Instance
}

IsInitialized | Out-Null
#endregion