SQLEventAnalysis.psm1

# SQLEventAnalysis.psm1
using namespace System.Collections.Generic
using namespace System.Management.Automation

#region Classes

class SQLActivityGroup {
    [int]$SessionId
    [string]$ActivityId
    [datetime]$FirstTimestamp
    [int]$EventCount
    hidden [System.Collections.ArrayList]$Events
    hidden [hashtable]$_eventsByType = @{}
    
    SQLActivityGroup([int]$sessionId, [string]$activityId, [System.Collections.ArrayList]$events) {
        $this.SessionId = $sessionId
        $this.ActivityId = $activityId
        $this.Events = $events
        $this.EventCount = $events.Count
        $this.FirstTimestamp = $events.Count -gt 0 ? $events[0].timestamp : [datetime]::MinValue
        
        # Pre-group events by type
        foreach ($e in $events) {
            $eventType = $e.name
            if (-not $this._eventsByType.ContainsKey($eventType)) {
                $this._eventsByType[$eventType] = [System.Collections.ArrayList]::new()
            }
            $this._eventsByType[$eventType].Add($e) | Out-Null
        }
    }
    
    [object[]] GetEventsByType([string]$eventType) {
        return $this._eventsByType[$eventType] ?? @()
    }
    
    [string[]] GetEventTypes() {
        return $this._eventsByType.Keys
    }
}

#endregion

#region Public Functions

function Add-DaxUsernameFromSqlContext {
    <#
    .SYNOPSIS
    Extracts Dynamics AX username from SQL Server context_info field.
     
    .DESCRIPTION
    Decodes the context_info binary field to extract the Dynamics AX username.
    This is specific to Dynamics AX implementations where username is stored in context_info.
     
    .PARAMETER InputObject
    The XE event object to process.
     
    .EXAMPLE
    Read-DbaXEFile -Path "C:\XEvents\*.xel" | Add-DaxUsernameFromSqlContext
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [object]$InputObject
    )
    
    process {
        $contextInfo = $InputObject.context_info
        
        if ($contextInfo) {
            $contextString = [Text.Encoding]::ASCII.GetString($contextInfo)
            $username = $contextString.Trim().Split(' ') | Select-Object -First 1
        } else {
            $contextString = ""
            $username = ""
        }
        
        # Add properties to the object
        $InputObject | 
            Add-Member -NotePropertyName "context_info_string" -NotePropertyValue $contextString -PassThru |
            Add-Member -NotePropertyName "usernameFromContext" -NotePropertyValue $username -PassThru
    }
}

function Group-DbaXEEventByActivity {
    <#
    .SYNOPSIS
    Groups Extended Events by session and activity ID.
     
    .DESCRIPTION
    Accumulates events by session_id and attach_activity_id. When an activity changes
    for a session, outputs the completed activity group with all its events.
     
    .PARAMETER InputObject
    The XE event object to process.
     
    .EXAMPLE
    $activities = Read-DbaXEFile -Path "*.xel" | Group-DbaXEEventByActivity
    #>

    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [object]$InputObject
    )
    
    begin {
        $sessionActivities = [Dictionary[int, Dictionary[string, System.Collections.ArrayList]]]::new()
        $currentActivityIds = [Dictionary[int, string]]::new()
        $processedCount = 0
    }
    
    process {
        $processedCount++
        
        # Extract session and activity IDs
        $sessionId = $InputObject.session_id ?? 0
        $activityIdGuid = $null
        
        if ($null -ne $InputObject.attach_activity_id) {
            $activityIdParts = $InputObject.attach_activity_id.ToString().Split(':')
            $activityIdGuid = $activityIdParts[0]
        }
        
        # Skip events without session or activity
        if ($sessionId -eq 0 -or [string]::IsNullOrEmpty($activityIdGuid)) {
            Write-Verbose "Skipping event without session/activity ID"
            return
        }
        
        # Initialize tracking for new sessions
        if (-not $sessionActivities.ContainsKey($sessionId)) {
            $sessionActivities[$sessionId] = [Dictionary[string, System.Collections.ArrayList]]::new()
            $currentActivityIds[$sessionId] = $null
        }
        
        # Check if activity changed
        if ($currentActivityIds[$sessionId] -and 
            $currentActivityIds[$sessionId] -ne $activityIdGuid) {
            
            # Output previous activity
            $previousActivityId = $currentActivityIds[$sessionId]
            $activityEvents = $sessionActivities[$sessionId][$previousActivityId]
            
            Write-Verbose "Outputting activity $previousActivityId with $($activityEvents.Count) events"
            
            # Create activity group
            $activityGroup = [SQLActivityGroup]::new($sessionId, $previousActivityId, $activityEvents)
            
            # Create output object with dynamic properties
            $output = [PSCustomObject]@{
                PSTypeName = 'SQLActivityGroup'
                SessionId = $activityGroup.SessionId
                ActivityId = $activityGroup.ActivityId
                FirstTimestamp = $activityGroup.FirstTimestamp
                EventCount = $activityGroup.EventCount
                Events = $activityEvents
            }
            
            # Add dynamic properties for each event type
            foreach ($eventType in $activityGroup.GetEventTypes()) {
                $output | Add-Member -MemberType NoteProperty -Name $eventType -Value ($activityGroup.GetEventsByType($eventType))
            }
            
            # Add common calculated properties
            $output | Add-Member -MemberType ScriptProperty -Name 'LastTimestamp' -Value {
                $this.Events[-1].timestamp
            }
            
            $output | Add-Member -MemberType ScriptProperty -Name 'Duration' -Value {
                ($this.LastTimestamp - $this.FirstTimestamp).TotalMilliseconds
            }
            
            $output | Add-Member -MemberType ScriptProperty -Name 'Username' -Value {
                # Try DAX username first, then regular username
                $daxUser = $this.Events | Where-Object usernameFromContext | Select-Object -First 1 -ExpandProperty usernameFromContext
                if ($daxUser) { $daxUser } else { $this.Events[0].username }
            }
            
            $output
            
            # Clean up
            $sessionActivities[$sessionId].Remove($previousActivityId)
        }
        
        # Update current activity
        $currentActivityIds[$sessionId] = $activityIdGuid
        
        # Initialize activity if needed
        if (-not $sessionActivities[$sessionId].ContainsKey($activityIdGuid)) {
            $sessionActivities[$sessionId][$activityIdGuid] = [System.Collections.ArrayList]::new()
        }
        
        # Add event to activity
        $sessionActivities[$sessionId][$activityIdGuid].Add($InputObject) | Out-Null
    }
    
    end {
        Write-Verbose "Outputting remaining activities"
        
        # Output remaining activities
        foreach ($sessionId in $sessionActivities.Keys) {
            foreach ($activityId in $sessionActivities[$sessionId].Keys) {
                $activityEvents = $sessionActivities[$sessionId][$activityId]
                
                # Create activity group
                $activityGroup = [SQLActivityGroup]::new($sessionId, $activityId, $activityEvents)
                
                # Create output object
                $output = [PSCustomObject]@{
                    PSTypeName = 'SQLActivityGroup'
                    SessionId = $activityGroup.SessionId
                    ActivityId = $activityGroup.ActivityId
                    FirstTimestamp = $activityGroup.FirstTimestamp
                    EventCount = $activityGroup.EventCount
                    Events = $activityEvents
                }
                
                # Add dynamic properties for each event type
                foreach ($eventType in $activityGroup.GetEventTypes()) {
                    $output | Add-Member -MemberType NoteProperty -Name $eventType -Value ($activityGroup.GetEventsByType($eventType))
                }
                
                # Add calculated properties
                $output | Add-Member -MemberType ScriptProperty -Name 'LastTimestamp' -Value {
                    $this.Events[-1].timestamp
                }
                
                $output | Add-Member -MemberType ScriptProperty -Name 'Duration' -Value {
                    ($this.LastTimestamp - $this.FirstTimestamp).TotalMilliseconds
                }
                
                $output | Add-Member -MemberType ScriptProperty -Name 'Username' -Value {
                    $daxUser = $this.Events | Where-Object usernameFromContext | Select-Object -First 1 -ExpandProperty usernameFromContext
                    if ($daxUser) { $daxUser } else { $this.Events[0].username }
                }
                
                $output
            }
        }
    }
}

function Measure-DbaXEActivity {
    <#
    .SYNOPSIS
    Calculates performance metrics for SQL activity groups.
     
    .DESCRIPTION
    Adds calculated performance metrics like TotalDuration, TotalCPU, TotalReads, etc.
    to activity group objects.
     
    .PARAMETER InputObject
    The activity group object to measure.
     
    .EXAMPLE
    $activities | Measure-DbaXEActivity
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [PSCustomObject]$InputObject
    )
    
    process {
        # Calculate metrics from all events
        $durationSum = ($InputObject.Events | Where-Object duration | Measure-Object -Property duration -Sum).Sum
        $cpuSum = ($InputObject.Events | Where-Object cpu_time | Measure-Object -Property cpu_time -Sum).Sum
        $physicalReadsSum = ($InputObject.Events | Where-Object physical_reads | Measure-Object -Property physical_reads -Sum).Sum
        $logicalReadsSum = ($InputObject.Events | Where-Object logical_reads | Measure-Object -Property logical_reads -Sum).Sum
        $writesSum = ($InputObject.Events | Where-Object writes | Measure-Object -Property writes -Sum).Sum
        
        # Add metric properties
        $InputObject |
            Add-Member -NotePropertyName 'TotalDuration' -NotePropertyValue $durationSum -PassThru |
            Add-Member -NotePropertyName 'TotalCPUTime' -NotePropertyValue $cpuSum -PassThru |
            Add-Member -NotePropertyName 'TotalPhysicalReads' -NotePropertyValue $physicalReadsSum -PassThru |
            Add-Member -NotePropertyName 'TotalLogicalReads' -NotePropertyValue $logicalReadsSum -PassThru |
            Add-Member -NotePropertyName 'TotalWrites' -NotePropertyValue $writesSum -PassThru
    }
}

function Group-DbaXEEventByQuery {
    <#
    .SYNOPSIS
    Groups events by normalized SQL query text.
     
    .DESCRIPTION
    Groups events by SQL text, normalizing queries by removing parameters and
    standardizing format for aggregation purposes.
     
    .PARAMETER InputObject
    Activity groups or events to group by query.
     
    .PARAMETER GroupByUser
    If specified, groups by username and query combination.
     
    .EXAMPLE
    $activities | Group-DbaXEEventByQuery | Sort-Object TotalDurationMS -Descending
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [PSCustomObject]$InputObject,
        
        [switch]$GroupByUser
    )
    
    begin {
        $queryGroups = @{}
    }
    
    process {
        # Extract events from activity or use directly
        $events = if ($InputObject.PSObject.Properties['Events']) {
            $InputObject.Events
        } else {
            @($InputObject)
        }
        
        foreach ($e in $events) {
            if (-not $e.sql_text) { continue }
            
            # Normalize SQL text
            $normalizedSql = Get-NormalizedSqlText -SqlText $e.sql_text
            
            # Create group key
            $groupKey = if ($GroupByUser) {
                $username = $e.usernameFromContext ?? $e.username ?? 'Unknown'
                "$username|$normalizedSql"
            } else {
                $normalizedSql
            }
            
            # Initialize group if needed
            if (-not $queryGroups.ContainsKey($groupKey)) {
                $queryGroups[$groupKey] = [System.Collections.ArrayList]::new()
            }
            
            $queryGroups[$groupKey].Add($e) | Out-Null
        }
    }
    
    end {
        foreach ($groupKey in $queryGroups.Keys) {
            $events = $queryGroups[$groupKey]
            
            # Parse group key
            if ($GroupByUser -and $groupKey.Contains('|')) {
                $parts = $groupKey.Split('|', 2)
                $username = $parts[0]
                $sqlText = $parts[1]
            } else {
                $username = $null
                $sqlText = $groupKey
            }
            
            # Calculate statistics
            $stats = Get-EventStatistics -Events $events
            
            # Create output object
            $output = [PSCustomObject]@{
                PSTypeName = 'SQLQueryGroup'
                SqlText = $sqlText
                Count = $events.Count
                TotalDurationMS = $stats.Duration.Sum / 1000
                AvgDurationMS = $stats.Duration.Average / 1000
                TotalCPUMS = $stats.CPU.Sum / 1000
                AvgCPUMS = $stats.CPU.Average / 1000
                TotalPhysicalReads = $stats.PhysicalReads.Sum
                AvgPhysicalReads = $stats.PhysicalReads.Average
                TotalLogicalReads = $stats.LogicalReads.Sum
                AvgLogicalReads = $stats.LogicalReads.Average
                TotalWrites = $stats.Writes.Sum
                AvgWrites = $stats.Writes.Average
                FirstExecution = $stats.FirstTimestamp
                LastExecution = $stats.LastTimestamp
                Events = $events
            }
            
            if ($username) {
                $output | Add-Member -NotePropertyName 'Username' -NotePropertyValue $username
            }
            
            $output
        }
    }
}

function Export-DbaXEQueryText {
    <#
    .SYNOPSIS
    Exports SQL query text with performance metadata to files.
     
    .DESCRIPTION
    Exports SQL queries to .sql files with performance statistics as comments.
     
    .PARAMETER InputObject
    Activity or query group objects to export.
     
    .PARAMETER OutputPath
    Directory to save the SQL files.
     
    .EXAMPLE
    $topQueries | Export-DbaXEQueryText -OutputPath "C:\Analysis\Queries"
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [PSCustomObject]$InputObject,
        
        [Parameter(Mandatory = $true)]
        [string]$OutputPath
    )
    
    begin {
        # Ensure output directory exists
        if (-not (Test-Path $OutputPath)) {
            New-Item -Path $OutputPath -ItemType Directory | Out-Null
        }
    }
    
    process {
        # Extract key information
        $username = $InputObject.Username ?? 'Unknown'
        $activityId = $InputObject.ActivityId ?? 'NoActivity'
        $duration = $InputObject.TotalDuration ?? $InputObject.TotalDurationMS ?? 0
        
        # Find RPC completed event
        $rpcEvent = if ($InputObject.rpc_completed) {
            $InputObject.rpc_completed | Select-Object -First 1
        } else {
            $InputObject.Events | Where-Object name -eq 'rpc_completed' | Select-Object -First 1
        }
        
        if (-not $rpcEvent) {
            Write-Warning "No rpc_completed event found for activity $activityId"
            return
        }
        
        # Build content
        $content = @"
-- Username: $username
-- Activity: $activityId
-- Duration: $($rpcEvent.duration) microseconds
-- CPU Time: $($rpcEvent.cpu_time) microseconds
-- Physical Reads: $($rpcEvent.physical_reads)
-- Logical Reads: $($rpcEvent.logical_reads)
-- Writes: $($rpcEvent.writes)
-- Row Count: $($rpcEvent.row_count)
 
$($rpcEvent.statement)
GO
$($rpcEvent.sql_text)
"@

        
        # Add stored procedure statements if available
        if ($InputObject.sp_statement_completed) {
            foreach ($spStmt in $InputObject.sp_statement_completed) {
                $dmvHash = Convert-XEHashToDMVHash -XEQueryHash $spStmt.query_hash
                $hexHash = "0x{0:X}" -f $dmvHash
                
                $content += @"
 
GO
-- Query Hash: $hexHash
-- Line: $($spStmt.line_number), Statement: $($spStmt.statement_number)
$($spStmt.statement)
"@

            }
        }
        
        # Generate filename
        $filename = "$username.$activityId.$([int]$duration).sql"
        $filepath = Join-Path $OutputPath $filename
        
        # Export
        $content | Out-File -FilePath $filepath -Encoding UTF8
        Write-Verbose "Exported query to: $filepath"
    }
}

function Export-DbaXEQueryPlan {
    <#
    .SYNOPSIS
    Exports SQL execution plans from showplan events.
     
    .DESCRIPTION
    Extracts and saves execution plans from query_post_execution_showplan events.
     
    .PARAMETER InputObject
    Activity objects containing showplan events.
     
    .PARAMETER OutputPath
    Directory to save the .sqlplan files.
     
    .EXAMPLE
    $activities | Export-DbaXEQueryPlan -OutputPath "C:\Analysis\Plans"
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [PSCustomObject]$InputObject,
        
        [Parameter(Mandatory = $true)]
        [string]$OutputPath
    )
    
    begin {
        if (-not (Test-Path $OutputPath)) {
            New-Item -Path $OutputPath -ItemType Directory | Out-Null
        }
    }
    
    process {
        # Find showplan events
        $showplanEvents = if ($InputObject.query_post_execution_showplan) {
            $InputObject.query_post_execution_showplan
        } else {
            $InputObject.Events | Where-Object name -eq 'query_post_execution_showplan'
        }
        
        if (-not $showplanEvents) {
            Write-Verbose "No execution plan found for activity $($InputObject.ActivityId)"
            return
        }
        
        foreach ($showplan in $showplanEvents) {
            $username = $InputObject.Username ?? 'Unknown'
            $activityId = $InputObject.ActivityId ?? 'NoActivity'
            $duration = $InputObject.TotalDuration ?? 0
            
            $filename = "$username.$activityId.$([int]$duration).sqlplan"
            $filepath = Join-Path $OutputPath $filename
            
            $showplan.showplan_xml | Out-File -FilePath $filepath -Encoding UTF8
            Write-Verbose "Exported execution plan to: $filepath"
        }
    }
}

function Export-DbaXEAnalysisReport {
    <#
    .SYNOPSIS
    Exports XE analysis results to Excel report.
     
    .DESCRIPTION
    Creates formatted Excel reports with query statistics and analysis results.
     
    .PARAMETER InputObject
    Query group objects to export.
     
    .PARAMETER Path
    Path for the Excel file.
     
    .PARAMETER WorksheetName
    Name of the worksheet. Defaults to 'QueryAnalysis'.
     
    .EXAMPLE
    $queryGroups | Export-DbaXEAnalysisReport -Path "C:\Reports\Analysis.xlsx"
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [PSCustomObject[]]$InputObject,
        
        [Parameter(Mandatory = $true)]
        [string]$Path,
        
        [string]$WorksheetName = 'QueryAnalysis'
    )
    
    begin {
        $allData = [System.Collections.ArrayList]::new()
    }
    
    process {
        foreach ($item in $InputObject) {
            $allData.Add($item) | Out-Null
        }
    }
    
    end {
        # Select and order columns for export
        $exportData = $allData | Select-Object @(
            @{Name='Username'; Expression={$_.Username ?? 'All Users'}}
            'SqlText'
            'Count'
            'TotalDurationMS'
            'AvgDurationMS'
            'TotalCPUMS'
            'AvgCPUMS'
            'TotalPhysicalReads'
            'AvgPhysicalReads'
            'TotalLogicalReads'
            'AvgLogicalReads'
            'TotalWrites'
            'AvgWrites'
            'FirstExecution'
            'LastExecution'
        )
        
        # Export to Excel
        $exportData | Export-Excel -Path $Path -WorksheetName $WorksheetName -TableName $WorksheetName -AutoSize -FreezeTopRow
        
        Write-Verbose "Exported $($exportData.Count) query groups to: $Path"
    }
}

function Convert-XEHashToDMVHash {
    <#
    .SYNOPSIS
    Converts Extended Events query hash to DMV query hash format.
     
    .DESCRIPTION
    Converts unsigned 64-bit XE query hash to signed bigint for DMV compatibility.
     
    .PARAMETER XEQueryHash
    The unsigned query hash from Extended Events.
     
    .EXAMPLE
    $dmvHash = Convert-XEHashToDMVHash -XEQueryHash $e.query_hash
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [uint64]$XEQueryHash
    )
    
    process {
        if ($XEQueryHash -gt 9223372036854775807) {
            [bigint]($XEQueryHash - 9223372036854775808)
        } else {
            [bigint]$XEQueryHash
        }
    }
}

#endregion

#region Private Functions

function Get-NormalizedSqlText {
    <#
    .SYNOPSIS
    Normalizes SQL text for grouping similar queries.
    #>

    [CmdletBinding()]
    param(
        [string]$SqlText
    )
    
    if ([string]::IsNullOrWhiteSpace($SqlText)) {
        return $SqlText
    }
    
    # For SELECT queries, extract from SELECT onwards
    $upperSql = $SqlText.ToUpper()
    $selectIndex = $upperSql.IndexOf('SELECT')
    
    if ($selectIndex -ge 0) {
        return $SqlText.Substring($selectIndex)
    }
    
    # For non-SELECT queries, truncate if too long
    if ($SqlText.Length -gt 300) {
        return $SqlText.Substring(0, 300)
    }
    
    return $SqlText
}

function Get-EventStatistics {
    <#
    .SYNOPSIS
    Calculates statistics for a collection of events.
    #>

    [CmdletBinding()]
    param(
        [System.Collections.ArrayList]$Events
    )
    
    $stats = @{
        Duration = $Events | Where-Object duration | Measure-Object -Property duration -Sum -Average
        CPU = $Events | Where-Object cpu_time | Measure-Object -Property cpu_time -Sum -Average
        PhysicalReads = $Events | Where-Object physical_reads | Measure-Object -Property physical_reads -Sum -Average
        LogicalReads = $Events | Where-Object logical_reads | Measure-Object -Property logical_reads -Sum -Average
        Writes = $Events | Where-Object writes | Measure-Object -Property writes -Sum -Average
        FirstTimestamp = ($Events | Sort-Object timestamp | Select-Object -First 1).timestamp
        LastTimestamp = ($Events | Sort-Object timestamp | Select-Object -Last 1).timestamp
    }
    
    return $stats
}

#endregion

# Export module members
Export-ModuleMember -Function @(
    'Add-DaxUsernameFromSqlContext',
    'Group-DbaXEEventByActivity',
    'Measure-DbaXEActivity',
    'Group-DbaXEEventByQuery',
    'Export-DbaXEQueryText',
    'Export-DbaXEQueryPlan',
    'Export-DbaXEAnalysisReport',
    'Convert-XEHashToDMVHash'
)