#Requires -Version 5.1
<#
.SYNOPSIS
Extracts and processes Disk I/O activity from ControlUp Disk Monitor log files and exports the top entries to text or csv
.DESCRIPTION
https://support.controlup.com/v1/docs/disk-monitor-overview
.PARAMETER dummy1
(Ignored) Placeholder parameter, passed by ControlUp for compatibility.
.PARAMETER top
Specifies the maximum number of results to display or export. Default is 20.
.PARAMETER last
Specifies the time window to analyze (e.g., '24h' for last 24 hours, '10m' for last 10 minutes). Default is '24h'.
.PARAMETER includeUNC
Determines whether to include UNC (network) paths in the results. Accepts 'Yes', 'No', 'True', or 'False'. Default is 'No'.
.PARAMETER includeFSlogix
Determines whether to include FSLogix VHD file activity in the results. Accepts 'Yes', 'No', 'True', or 'False'. Default is 'Yes'.
.PARAMETER csvOutputFile
Specifies the path to a CSV file where results will be exported. If not specified, results are displayed in the console.
Folder willbe created if it does not exist.
If the file already exists, it will be overwritten.
.PARAMETER sortBy
Specifies the property to sort results by. Default is 'TransferSizeMB'.
.PARAMETER ascending
If specified, sorts results in ascending order. By default, results are sorted in descending order.
.PARAMETER delimiter
Specifies the delimiter to use when exporting to CSV. Default is ','.
.PARAMETER LogFilePattern
Specifies the file name pattern to match log files. Default is 'ActivitiesSummary*'.
.PARAMETER LogDirectory
Specifies the directory containing log files. If not specified, the script attempts to auto-detect the location.
.PARAMETER deleteOlderThanDays
Deletes log files older than the specified number of days. Default is 0 (no deletion).
.PARAMETER cu4d
Switch for manual debugging; forces ControlUp for Desktops (CU4D) mode.
.PARAMETER raw
If specified, outputs raw objects instead of formatted output or CSV/JSON.
.NOTES
2025/01/01 Chris Twiest Original version
2025/05/20 Guy Leech Added support for FSlogix (where filename is VHD*Filename)
2025/06/05 Guy Leech Fixed issue when there are multiple log files to process
2025/06/16 Guy Leech Added parameters for filtering UNC and/or FSlogix.
2025/06/17 Guy Leech Added code to deal with non-default log file locations
2025/06/19 Guy Leech Fixed bug in log file delete logic
2025/06/24 Guy Leech Added system drive free disk space to output. Removed property filtering on csv export
#>
[CmdletBinding()]
Param
(
[string]$dummy1 , ## passed by CU4D, ignored
[int]$top = 20 ,
[string]$last = '24h' ,
[ValidateSet('Yes','No','True','False')]
[string]$includeUNC = 'No' ,
[ValidateSet('Yes','No','True','False')]
[string]$includeFSlogix = 'Yes' ,
[string]$csvOutputFile ,
[string]$sortBy = 'TransferSizeMB' ,
[switch]$ascending ,
[string]$delimiter = ',' ,
[string]$LogFilePattern = "ActivitiesSummary*" ,
[string]$LogDirectory ,
[decimal]$deleteOlderThanDays = 7 , # Delete logs older then x days if found
[switch]$cu4d , ## for manual debugging only so can force CU4D mode
[switch]$raw
)
######## add regular expressions here which when matched to the full path name of the file will exclude them from the results #################
[string[]]$excludedPaths = @(
"^$env:SystemDrive\\pagefile\.sys$"
"^$env:SystemDrive\\\`$" ## NTFS metadata files
"^$env:SystemDrive\\Windows\\System32\\config\\" ## registry hives (can't use SystemRoot as \ would need escaping)
)
## figure out what ControlUp product is calling us so we can behave accordingly
[int]$parentProcessPid = (Get-CimInstance -ClassName win32_process -Filter "ProcessId = '$pid'" -Verbose:$false).ParentProcessId
[string]$thisProcessName = (Get-Process -Id $pid).Name
[string]$parentProcessName = $null
[bool]$isControlUpForDesktops = $false
$parentProcess = $null
if( $parentProcessPid -ge 4 )
{
$parentProcess = Get-Process -Id $parentProcessPid -ErrorAction SilentlyContinue
$parentProcessName = $parentProcess | Select-Object -ExpandProperty Name -ErrorAction SilentlyContinue
}
$isControlUpForDesktops = ( $parentProcessName -ieq 'SIPAgent' ) -or $cu4d
Write-Verbose "Pid $pid, parent $parentProcessName, CU4D $isControlUpForDesktops"
if( $isControlUpForDesktops )
{
try
{
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # This line is added to help with Unicode characters
}
catch
{
## probably because running in ISE
}
}
else
{
$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { $VerbosePreference } else { 'SilentlyContinue' })
$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { $DebugPreference } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'erroraction' ] ) { $ErrorActionPreference } else { 'Stop' })
$ProgressPreference = 'SilentlyContinue'
try
{
[int]$outputWidth = 400
if( ( $PSWindow = (Get-Host).UI.RawUI ) -and ( $WideDimensions = $PSWindow.BufferSize ) )
{
$WideDimensions.Width = $outputWidth
$PSWindow.BufferSize = $WideDimensions
}
else {
Write-Warning "PROBLEM GETTING WIDTH"
}
}
catch
{
## nothing much we can do but not fatal, just output possibly wrapping prematurely
Write-Warning "PROBLEM SETTING WIDTH : $_"
}
}
[datetime]$startDate = [datetime]::Today
if( $last.Trim('" ') -match '^([\d]*\.?[\d]*)\s*([smhdwy])?' ) { ## 24h 10m
[int]$multiplier = 3600 ## default is hours
if( -Not [string]::IsNullOrEmpty( $matches[2] ) ) {
switch( $matches[2] )
{
"s" { $multiplier = 1 }
"m" { $multiplier = 60 }
"h" { $multiplier = 3600 }
"d" { $multiplier = 86400 }
"w" { $multiplier = 86400 * 7 }
"y" { $multiplier = 86400 * 365 }
default { Throw "Unknown multiplier `"$($matches[2])`"" }
}
}
[decimal]$secondsBack = ($matches[1] -as [decimal]) * $multiplier
if( $secondsBack -le 0 ) {
Throw "$last resulted in invalid offset $secondsBack"
}
$startDate = [datetime]::Now.AddSeconds( -$secondsBack )
Write-Verbose "Start date is $($startDate.ToString('G'))"
}
else {
Throw "Cannot interpret -last $last"
}
[string[]]$displayProperties = @( 'Timestamp', 'ProcessName', 'UserName', 'TransferSizeMB', 'FileName' )
[bool]$updatedProperties = $false
if( [string]::IsNullOrEmpty( $LogDirectory )) {
$service = Get-CimInstance -ClassName win32_service -filter "name = 'ControlUp Disk Monitor'" -Verbose:$false -ErrorAction SilentlyContinue
if( $null -eq $service ) {
Write-Warning -Message "Failed to find disk monitor service to get log file folder, is this the correct machine & if so you may need to use -LogDirectory"
}
else {
if( $service.PathName -match '^"([^"]+)"' -or $service.PathName -match '^([^\s]+)' ) {
$serviceFolder = Split-Path -Path $matches[1] -Parent
if( Test-Path -Path $serviceFolder -PathType Container ) {
$log4netFile = Join-Path -Path $serviceFolder -ChildPath 'log4net.config'
if( -Not ( Test-Path -Path $log4netFile -PathType Leaf ) ) {
Write-Warning "Log2net config `"$log4netFile`" not found"
}
else {
[xml]$logConfig = Get-Content -Path $log4netFile -ErrorAction Continue
if( $null -ne $logConfig ) {
[string]$logFileBase = ($logConfig.log4net.appender|Where-Object name -ieq 'SummaryRollingFileAppender').file.value
if( [string]::IsNullOrEmpty( $logFileBase ) ) {
Write-Warning "Failed to find SummaryRollingFileAppender section log4net config file $log4netFile"
}
else {
$LogDirectory = Split-Path -Path $logFileBase -Parent
if( -Not ( Test-Path -Path $LogDirectory -PathType Container ) ) {
Write-Warning "Log directory `"$LogDirectory`" from log4net config file does not exist"
$LogDirectory = $null
}
else {
if( -Not $PSBoundParameters[ 'LogFilePattern' ] ) {
$LogFilePattern = (Split-Path -Path $logFileBase -Leaf) + '*'
}
Write-Verbose "Using log directory $LogDirectory and pattern $LogFilePattern from $log4netFile"
}
}
}
}
}
else {
Write-Warning "Folder `"serviceFolder`" for disk monitor service deso not exist. Fetched from $($service.Pathname)"
}
}
else {
Write-Warning -Message "Failed to determine folder from service path $($service.Pathname)"
}
}
if( [string]::IsNullOrEmpty( $LogDirectory )) {
$LogDirectory = Join-Path -Path ([Environment]::GetFolderPath( [Environment+SpecialFolder]::CommonApplicationData )) -ChildPath 'ControlUp\DiskMonitor'
}
}
# Get all matching log files
$AllLogFiles = @( Get-ChildItem -Path $LogDirectory -Filter $LogFilePattern -File )
# Filter files last written to within the required time period
$RecentLogFiles = @( $AllLogFiles | Where-Object LastWriteTime -ge $startDate | Sort-Object LastWriteTime -Descending )
# Filter and delete files older than x days
if( $deleteOlderThanDays -gt 0 ) {
[datetime]$CutoffDate = (Get-Date).AddDays( -$deleteOlderThanDays )
$OldLogFiles = $AllLogFiles | Where-Object CreationTime -lt $CutoffDate
if ($OldLogFiles) {
Write-Output "INFO: Deleting $(($OldLogFiles).Count) log file(s) older than 7 days..."
$OldLogFiles | ForEach-Object {
Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
if( -Not $raw ) {
Write-Output "Deleted: $($_.FullName)"
}
}
}
}
# Check if there are logs created in the time window
if ( $null -eq $RecentLogFiles -or $RecentLogFiles.Count -eq 0 ) {
Throw "ERROR: No log files created since $($startDate.ToString('G')) in $LogDirectory"
}
[int]$diskFreeSpaceGB = [math]::Round( (Get-PSDrive -Name $env:SystemDrive[0]).Free / 1GB , 2 )
try {
if( -Not $raw ) {
Write-Output "Reading log file(s): $($RecentLogFiles.Name -join ' , ') for activity since $($startDate.ToString('G')), disk free space $diskFreeSpaceGB GB"
}
# Initialize a list to store results efficiently
$Results = @( ForEach( $file in $RecentLogFiles.FullName ) {
## 1 2 3 4 5 6 7
[IO.File]::ReadLines( $file ) | Where-Object { $_ -match 'Timestamp:(.*?), Interval:(\d+), Process Id:(\d+), ProcessName:(.*?), UserName:(.*?), FileName:(.*?), TransferSize \(MB\):([\d,\.]+)' } | ForEach-Object {
[string]$fileName = $matches[6].Trim()
[bool]$excluded = $false
ForEach( $pattern in $excludedPaths ) {
if( $excluded = ( $fileName -match $pattern )) {
break
}
}
if( -Not $excluded ) {
$timestamp = $( if( $timestamp = $matches[1] -as [datetime] ) { $timestamp } else { [datetime]$matches[1] })
$excluded = $timestamp -lt $startDate
}
if( -Not $excluded ) {
$object = [PSCustomObject]@{
Timestamp = $timestamp
Interval = [int]$matches[2]
ProcessId = [int]$matches[3]
ProcessName = $matches[4].Trim()
UserName = $matches[5].Trim()
FileName = $fileName
TransferSizeMB = [math]::Round(($matches[7] -replace ",", ".") -as [double], 2)
FreeDiskSpace = $diskFreeSpaceGB
}
## file name is probably FSlogix if VHD*FileName
if( $object.FileName -match '^(.+)\*(.+)$' ) {
if( $includeFSlogix -eq 'Yes' -or $includeFSlogix -ieq 'True' ) {
Add-Member -InputObject $object -Force -NotePropertyMembers @{
VHD = $matches[1]
FileName = $matches[2]
}
if( -Not $updatedProperties ) {
$displayProperties += 'VHD'
$updatedProperties = $true
}
}
else {
$object = $null
}
}
elseif( ( $includeUNC -eq 'No' -or $includeUNC -ieq 'False' ) -and $object.FileName -match '^\\\\[^\\]+\\' )
{
$object = $null
}
if( $null -ne $object ) {
$object ## output to pipeline
}
}
}
})
if( $Results.Count -eq 0 ) {
Throw "No unfiltered results read from $LogFilePath"
}
# Sort and select top results
$TopResults = @( $Results | Sort-Object -Property $sortBy -Descending:(-Not $ascending) | Select-Object -First $Top )
if( $isControlUpForDesktops ) {
Write-Output("### SIP DATA BEGINS ###")
## deal with dates that export as /Date(1750065158000)/ which CU4D does not like
$TopResults | Select-Object -ExcludeProperty Timestamp -Property @{ name = 'TimeStamp' ; expression = { $_.timestamp.ToString('o') } },* | ConvertTo-Json -Depth 2 -Compress
Write-Output("### SIP DATA ENDS ###")
Write-Output("### SIP EVENT BEGINS ###")
Write-Output "Top $Top results exported to JSON"
Write-Output("### SIP EVENT ENDS ###")
}
elseif( $raw ){
$TopResults
}
elseif( -Not [string]::IsNullOrEmpty( $csvOutputFile )) {
$folder = Split-Path -Path $csvOutputFile -Parent
if( -Not ( Test-Path -Path $folder -PathType Container ) ) {
$null = New-Item -Path $folder -Force -ItemType Directory
}
$TopResults | Export-Csv -Path $csvOutputFile -NoTypeInformation -Delimiter $delimiter
if( $? ) {
Write-Output "Results written to $csvOutputFile"
}
}
else {
$TopResults | Select-Object -Property $displayProperties | Format-Table -AutoSize | Out-String -Width $outputWidth
}
} catch {
Write-Output "ERROR: An error occurred while processing the log file: $_"
exit 1
}