W3CLogs.psm1

# Copyright WebMD Health Services
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License

#Requires -Version 5.1
Set-StrictMode -Version 'Latest'

# Functions should use $moduleRoot as the relative root from which to find
# things. A published module has its function appended to this file, while a
# module in development has its functions in the Functions directory.
$script:moduleRoot = $PSScriptRoot

$script:fieldPropertyMap = @{
    'date' = 'Date';
    'time' = 'Time';
    's-ip' = 'ServerIP';
    'cs-method' = 'Method';
    'cs-uri-stem' = 'Stem';
    'cs-uri-query' = 'Query';
    's-port' = 'Port';
    'cs-username' = 'UserName';
    'c-ip' = 'ClientIP';
    'cs-version' = 'Version';
    'cs(User-Agent)' = 'UserAgent';
    'cs(Cookie)' = 'Cookie';
    'cs(Referer)' = 'Referer';
    'cs-host' = 'Host';
    'sc-status' = 'Status';
    'sc-bytes' = 'BytesSent';
    'cs-bytes' = 'BytesReceived';
    'time-taken' = 'TimeTaken';
    's-sitename' = 'SiteName';
    's-computername' = 'ComputerName';
    'sc-substatus' = 'Substatus';
    'sc-win32-status' = 'Win32Status';
}

$script:milliseconds = [Collections.Generic.Hashset[String]]::New()
[void]$script:milliseconds.Add('time-taken')

$script:httpMethods = [Collections.Generic.Hashset[String]]::New()
[void]$script:httpMethods.Add('sc-method')

$srcPath = Join-Path -Path $script:moduleRoot -ChildPath 'src' -Resolve

Add-Type -Path (Get-ChildItem -Path $srcPath -Filter '*.cs').FullName `
         -ReferencedAssemblies 'System.Net.Http','System.Net','System.Net.Primitives'

# Store each of your module's functions in its own file in the Functions
# directory. On the build server, your module's functions will be appended to
# this file, so only dot-source files that exist on the file system. This allows
# developers to work on a module without having to build it first. Grab all the
# functions that are in their own files.
$functionsPath = Join-Path -Path $moduleRoot -ChildPath 'Functions\*.ps1'
if( (Test-Path -Path $functionsPath) )
{
    foreach( $functionPath in (Get-Item $functionsPath) )
    {
        . $functionPath.FullName
    }
}



function Import-W3CLog
{
    <#
    .SYNOPSIS
    Parses and imports W3C log files.
 
    .DESCRIPTION
    The `Import-W3CLog` function parses and imports W3C log files, returning objects representing each line. Pass the
    path to the log file to parse to the `Path` parameter, or to parse multiple files, pipe them into the function.
 
    .EXAMPLE
    Import-W3CLog -Path log.log
 
    Demonstrates how to parse and import a single W3C log file by passing its path to the `Path` parameter.
 
    .EXAMPLE
    Get-ChildItem -Path C:\Inetpub\logs -Filter '*.log' -Recurse | Import-W3CLog
 
    Demonstrates how to parse multiple logs by piping their paths to the `Import-W3CLog` function.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [String] $Path
    )

    process
    {
        Set-StrictMode -Version 'Latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

        $Path = $Path | Resolve-Path
        if (-not $Path)
        {
            return
        }

        Write-Verbose "$($Path | Resolve-Path -Relative)"
        $fields = @()

        foreach ($line in (Get-Content -Path $Path))
        {
            if (-not $line)
            {
                continue
            }

            if ($line.StartsWith('#'))
            {
                if ($line.StartsWith('#Fields: '))
                {
                    $fields = $line.Split(' ') | Select-Object -Skip 1
                }
                else
                {
                    Write-Verbose " $($line.Substring(1))"
                }
                continue
            }

            $entry = [W3CLogs.LogEntry]::New()

            [String[]]$values = $line.Split(' ')
            for ($idx = 0; $idx -lt $values.Length; ++$idx)
            {
                $propertyName = $fieldName = $fields[$idx]
                if ($script:fieldPropertyMap.ContainsKey($fieldName))
                {
                    $propertyName = $script:fieldPropertyMap[$fieldName]
                }
                else
                {
                    $entry | Add-Member -Name $fieldName -MemberType NoteProperty
                }

                $value = $values[$idx]
                if ($value -eq '-')
                {
                    continue
                }

                if ($script:httpMethods.Contains($fieldName))
                {
                    $value = [Net.Http.HttpMethod]::New($value)
                }
                elseif ($script:milliseconds.Contains($fieldName))
                {
                    $value = [TimeSpan]::New(0, 0, 0, 0, $value)
                }

                $entry.$propertyName = $value
            }

            $entry.DateTime = $entry.Date + $entry.Time

            $hostname = 'example.com'
            if ($entry.Host)
            {
                $hostname = $entry.Host
            }
            elseif ($entry.ServerIP)
            {
                $hostname = $entry.ServerIP.IPAddressToString
            }

            $queryString = ''
            if ($entry.Query)
            {
                $queryString = "?$($entry.Query)"
            }

            $entry.Url = [Uri]::New("http://$($hostname)$($entry.Stem)$($queryString)")

            $entry | Write-Output
        }

    }
}


function Use-CallerPreference
{
    <#
    .SYNOPSIS
    Sets the PowerShell preference variables in a module's function based on the callers preferences.
 
    .DESCRIPTION
    Script module functions do not automatically inherit their caller's variables, including preferences set by common
    parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't
    get passed into any function that belongs to a module.
 
    When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the
    function's caller:
 
     * ErrorAction
     * Debug
     * Confirm
     * InformationAction
     * Verbose
     * WarningAction
     * WhatIf
     
    This function should be used in a module's function to grab the caller's preference variables so the caller doesn't
    have to explicitly pass common parameters to the module function.
 
    This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d).
 
    There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that
    causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add
    explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get
    fixed.
 
    .LINK
    about_Preference_Variables
 
    .LINK
    about_CommonParameters
 
    .LINK
    https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
 
    .LINK
    http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/
 
    .EXAMPLE
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
    Demonstrates how to set the caller's common parameter preference variables in a module function.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        #[Management.Automation.PSScriptCmdlet]
        # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]`
        # attribute.
        $Cmdlet,

        [Parameter(Mandatory)]
        # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the
        # `[CmdletBinding()]` attribute.
        #
        # Used to set variables in its callers' scope, even if that caller is in a different script module.
        [Management.Automation.SessionState]$SessionState
    )

    Set-StrictMode -Version 'Latest'

    # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken
    # from about_CommonParameters).
    $commonPreferences = @{
                              'ErrorActionPreference' = 'ErrorAction';
                              'DebugPreference' = 'Debug';
                              'ConfirmPreference' = 'Confirm';
                              'InformationPreference' = 'InformationAction';
                              'VerbosePreference' = 'Verbose';
                              'WarningPreference' = 'WarningAction';
                              'WhatIfPreference' = 'WhatIf';
                          }

    foreach( $prefName in $commonPreferences.Keys )
    {
        $parameterName = $commonPreferences[$prefName]

        # Don't do anything if the parameter was passed in.
        if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) )
        {
            continue
        }

        $variable = $Cmdlet.SessionState.PSVariable.Get($prefName)
        # Don't do anything if caller didn't use a common parameter.
        if( -not $variable )
        {
            continue
        }

        if( $SessionState -eq $ExecutionContext.SessionState )
        {
            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
        }
        else
        {
            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
        }
    }
}