3CX v20u6 Max Concurrent Calls Script

Mr. Sea

Silver Partner
Advanced Certified
Joined
Dec 18, 2024
Messages
18
Reaction score
11
Hey y'all, sharing out my script to get the max concurrent calls for v20. You need to have your CDR enabled and in single file format for this script. Since there is no report or status on this usage as of today. You can download the cdr.log from your on-prem PBX @ /var/lib/3cxpbx/Instance1/Data/Logs/CDRLogs/ - Run the PS1 from the same directory as the downloaded cdr.log file.

*Edit, appears some things changed in u7, I'll make a separate post when I adjust script for that one in the next day.

Code:
# 3CX v20 CDR Analyzer (FAST, 1-second progress, starts-before-ends, reliable peak time)
# Assumes standard v20 layout: start = column 3, end = column 5 (0-based), unquoted CSV.

param(
  [string]$Path = "cdr.log",
  [int]$ProgressIntervalSeconds = 1
)

# ---------------- CONFIG ----------------
[string[]]$formats = @('yyyy/MM/dd HH:mm:ss')
$culture = [System.Globalization.CultureInfo]::InvariantCulture
$style   = [System.Globalization.DateTimeStyles]::None

# Resolve relative path safely
$fullPath = Join-Path -Path (Get-Location) -ChildPath $Path
if (-not (Test-Path $fullPath)) {
    Write-Error "CDR file not found: $fullPath"
    exit
}

# Field indices for 3CX v20 (0-based)
$startIdx = 3
$endIdx   = 5

# ---------------- HELPERS ----------------
function Get-Field {
    param([string]$line, [int]$index)
    $len = $line.Length; $start = 0; $commaCount = 0
    for ($i = 0; $i -lt $len; $i++) {
        if ($line[$i] -eq ',') {
            if ($commaCount -eq $index) { return $line.Substring($start, $i - $start) }
            $commaCount++; $start = $i + 1
        }
    }
    if ($commaCount -eq $index) { return $line.Substring($start) }
    return $null
}

function Parse-Time {
    param([string]$s)
    if ([string]::IsNullOrWhiteSpace($s)) { return $null }
    [datetime]$out = [datetime]::MinValue
    if ([datetime]::TryParseExact($s, $formats, $culture, $style, [ref]$out)) { $out } else { $null }
}

# ---------------- STATE ----------------
# Separate hashtables so we apply starts BEFORE ends at each timestamp
$startsAt = New-Object System.Collections.Hashtable 200000
$endsAt   = New-Object System.Collections.Hashtable 200000

[datetime]$minStart = [datetime]::MaxValue
[datetime]$maxEnd   = [datetime]::MinValue
$haveStart = $false
$haveEnd   = $false

$maxConcurrent       = 0
$currentConcurrent   = 0
[datetime]$peakTimestamp = [datetime]::MinValue  # sentinel instead of Nullable

# ---------------- MAIN LOOP (FAST + 1s PROGRESS) ----------------
$fs = [System.IO.File]::OpenRead($fullPath)
try {
    $sr = New-Object System.IO.StreamReader($fs)
    $totalBytes = $fs.Length
    $lines = 0
    $lastTick = [datetime]::UtcNow

    while (($line = $sr.ReadLine()) -ne $null) {
        $lines++
        if ([string]::IsNullOrWhiteSpace($line)) { continue }

        # Time-based progress update (~every 1 second)
        if (([datetime]::UtcNow - $lastTick).TotalSeconds -ge $ProgressIntervalSeconds) {
            $lastTick = [datetime]::UtcNow
            $pct = if ($totalBytes -gt 0) { [math]::Round(($fs.Position / $totalBytes) * 100, 2) } else { 0 }
            Write-Progress -Activity "Analyzing 3CX CDR..." -Status "Processed $lines lines" -PercentComplete $pct
        }

        # Extract start/end (assumes unquoted CSV)
        $startStr = Get-Field -line $line -index $startIdx
        $endStr   = Get-Field -line $line -index $endIdx

        # Parse and accumulate
        $start = Parse-Time $startStr
        if ($start) {
            if ($startsAt.ContainsKey($start)) { $startsAt[$start] += 1 } else { $startsAt[$start] = 1 }
            if ($start -lt $minStart) { $minStart = $start; $haveStart = $true }
        }

        $end = Parse-Time $endStr
        if ($end) {
            if ($endsAt.ContainsKey($end)) { $endsAt[$end] += 1 } else { $endsAt[$end] = 1 }
            if ($end -gt $maxEnd) { $maxEnd = $end; $haveEnd = $true }
        }
    }
}
finally {
    if ($sr) { $sr.Close() }
    if ($fs) { $fs.Close() }
}

# ---------------- SWEEP (STARTS BEFORE ENDS + RELIABLE PEAK TIME) ----------------
# Force keys to true [datetime] before sorting (prevents string-typing edge cases)
$allTs = ($startsAt.Keys + $endsAt.Keys) | ForEach-Object { if ($_ -is [datetime]) { $_ } else { [datetime]$_ } } | Sort-Object -Unique

foreach ($ts in $allTs) {
    if ($startsAt.ContainsKey($ts)) { $currentConcurrent += $startsAt[$ts] }   # starts first
    if ($currentConcurrent -gt $maxConcurrent) {
        $maxConcurrent = $currentConcurrent
        $peakTimestamp = $ts
    }
    if ($endsAt.ContainsKey($ts))   { $currentConcurrent -= $endsAt[$ts] }     # then ends
}

Write-Progress -Activity "Analyzing 3CX CDR..." -Completed -Status "Done!"

# ---------------- OUTPUT ----------------
$fmt = 'yyyy/MM/dd HH:mm:ss'
$windowStart = if ($haveStart) { $minStart.ToString($fmt) } else { '(unknown)' }
$windowEnd   = if ($haveEnd)   { $maxEnd.ToString($fmt) } else { '(unknown)' }

Write-Host "---------------------------------------------"
Write-Host (" Max concurrent calls : {0}" -f $maxConcurrent)
if ($peakTimestamp -ne [datetime]::MinValue) {
    Write-Host (" Peak first occurred  : {0}" -f $peakTimestamp.ToString($fmt))
} else {
    Write-Host " Peak first occurred  : (not found)"
}
Write-Host (" Time window analyzed  : {0}  →  {1}" -f $windowStart, $windowEnd)
Write-Host (" Lines processed       : {0}" -f $lines)
Write-Host "---------------------------------------------"
 
Last edited:
  • Like
Reactions: SweetAction
Hey y'all, sharing out my script to get the max concurrent calls for v20.
v20u7?

Code:
---------------------------------------------
 Max concurrent calls : 0
 Peak first occurred  : (not found)
 Time window analyzed  : (unknown)  ?  (unknown)
 Lines processed       : 33048
---------------------------------------------

sample line:
Code:
Call 00000000-01dc-42a0-c8e7-8e53000000fd,00000000-01dc-42a0-c8e4-28ac00000046,,2025.10.21 15:38:44,,2025.10.21 15:38:53,src_participant_terminated,Ext.123,123456789,123,10099,123456789,,123456789,,,,,,Chain: Ext.123;123456789;,extension,external_line,outbound_rule,123user,123456789,rulename,
 
on v20u6, I'll update 3CX and check on u7, if any changes will update code back here
 
  • Like
Reactions: fxbastler
on v20u6, I'll update 3CX and check on u7, if any changes will update code back here
Tested on v20u7, it actually does work, did you change your ordering around? The original script uses the start and end times at positions 3 and 5 in the CSV. I also noticed your date format uses . where mine uses /, I've adjusted to check for different formatting as well. I've updated the script so it can auto detect if the fields have been moved around.
 
Last edited:
Code:
# 3CX v20 CDR Analyzer (FAST, auto-detect start/end indices, 1s progress, starts-before-ends)
# Works even if 3CX field order changes and whether timestamps use '/' or '.' or '-'.

param(
  [string]$Path = "cdr.log",
  [int]$ProgressIntervalSeconds = 1
)

# ---------------- CONFIG ----------------
# Accept multiple timestamp formats (slash, dot, dash)
[string[]]$formats = @(
  'yyyy/MM/dd HH:mm:ss',
  'yyyy.MM.dd HH:mm:ss',
  'yyyy-MM-dd HH:mm:ss'
)
$culture = [System.Globalization.CultureInfo]::InvariantCulture
$style   = [System.Globalization.DateTimeStyles]::None   # 3CX writes local timestamps as-is

# Resolve relative path safely
$fullPath = Join-Path -Path (Get-Location) -ChildPath $Path
if (-not (Test-Path $fullPath)) {
    Write-Error "CDR file not found: $fullPath"
    exit
}

# ---------------- HELPERS ----------------
function Parse-Time {
    param([string]$s)
    if ([string]::IsNullOrWhiteSpace($s)) { return $null }
    [datetime]$out = [datetime]::MinValue
    if ([datetime]::TryParseExact($s, $formats, $culture, $style, [ref]$out)) { $out } else { $null }
}

# Fast field extractor for UNQUOTED lines (0-based index)
function Get-Field {
    param([string]$line, [int]$index)
    $len = $line.Length; $start = 0; $commas = 0
    for ($i = 0; $i -lt $len; $i++) {
        if ($line[$i] -eq ',') {
            if ($commas -eq $index) { return $line.Substring($start, $i - $start) }
            $commas++; $start = $i + 1
        }
    }
    if ($commas -eq $index) { return $line.Substring($start) }
    return $null
}

# Lightweight CSV splitter for QUOTED lines (handles quotes and escaped quotes)
function Split-CSVQuotedLight {
    param([string]$line)
    $list = New-Object System.Collections.Generic.List[string]
    $sb = New-Object System.Text.StringBuilder
    $inQuotes = $false
    for ($i=0; $i -lt $line.Length; $i++) {
        $ch = $line[$i]
        if ($ch -eq '"') {
            if ($inQuotes -and $i + 1 -lt $line.Length -and $line[$i+1] -eq '"') {
                [void]$sb.Append('"'); $i++   # escaped double quote ""
            } else {
                $inQuotes = -not $inQuotes
            }
        } elseif ($ch -eq ',' -and -not $inQuotes) {
            $list.Add($sb.ToString()); $sb.Clear() | Out-Null
        } else {
            [void]$sb.Append($ch)
        }
    }
    $list.Add($sb.ToString())
    return $list.ToArray()
}

# ---------------- QUICK PROBE: detect time-start / time-end indices ----------------
[int]$startIdx = -1
[int]$endIdx   = -1
$hasQuotesInFile = $false

# Read a handful of lines to detect
$probeLines = Get-Content -LiteralPath $fullPath -TotalCount 400 | Where-Object { $_.Trim() } | Select-Object -First 200
if (-not $probeLines) {
    Write-Error "CDR file appears empty: $fullPath"
    exit
}

# Gather indices that parse as datetime across probe lines
$idxCounts = @{}     # index -> count of times parsed as DateTime
$maxFieldsSeen = 0

foreach ($pl in $probeLines) {
    if ($pl.Contains('"')) { $hasQuotesInFile = $true }
    $parts = if ($pl.Contains('"')) { Split-CSVQuotedLight $pl } else { $pl.Split(',', [System.StringSplitOptions]::None) }
    if ($parts.Count -gt $maxFieldsSeen) { $maxFieldsSeen = $parts.Count }
    for ($i=0; $i -lt $parts.Count; $i++) {
        if (Parse-Time ($parts[$i].Trim())) {
            if ($idxCounts.ContainsKey($i)) { $idxCounts[$i]++ } else { $idxCounts[$i] = 1 }
        }
    }
}

# Candidate datetime columns sorted by index (stable)
$datetimeIndices = $idxCounts.Keys | Sort-Object
if ($datetimeIndices.Count -lt 2) {
    Write-Error "Could not auto-detect both time-start and time-end columns. Found: $($datetimeIndices -join ', ')."
    Write-Error "Ensure your CDR uses one of: yyyy/MM/dd HH:mm:ss, yyyy.MM.dd HH:mm:ss, yyyy-MM-dd HH:mm:ss."
    exit
}

# Heuristic:
# - leftmost datetime col = time-start
# - rightmost datetime col = time-end
$startIdx = $datetimeIndices[0]
$endIdx   = $datetimeIndices[-1]

# Sanity: if many rows show end < start, swap (rare)
[int]$validOrder = 0; [int]$swappedOrder = 0
foreach ($pl in ($probeLines | Select-Object -First 50)) {
    $parts = if ($pl.Contains('"')) { Split-CSVQuotedLight $pl } else { $pl.Split(',', [System.StringSplitOptions]::None) }
    if ($parts.Count -le [Math]::Max($startIdx,$endIdx)) { continue }
    $s = Parse-Time ($parts[$startIdx].Trim()); $e = Parse-Time ($parts[$endIdx].Trim())
    if ($s -and $e) {
        if ($e -ge $s) { $validOrder++ } else { $swappedOrder++ }
    }
}
if ($swappedOrder -gt $validOrder) { $tmp = $startIdx; $startIdx = $endIdx; $endIdx = $tmp }

Write-Host ("[Auto-detect] time-start index = {0}, time-end index = {1} (quotes in file: {2})" -f $startIdx, $endIdx, $hasQuotesInFile)

# ---------------- STATE ----------------
$startsAt = New-Object System.Collections.Hashtable 200000
$endsAt   = New-Object System.Collections.Hashtable 200000

[datetime]$minStart = [datetime]::MaxValue
[datetime]$maxEnd   = [datetime]::MinValue
$haveStart = $false
$haveEnd   = $false

$maxConcurrent       = 0
$currentConcurrent   = 0
[datetime]$peakTimestamp = [datetime]::MinValue  # sentinel

# ---------------- MAIN LOOP (FAST + 1s PROGRESS) ----------------
$fs = [System.IO.File]::OpenRead($fullPath)
try {
    $sr = New-Object System.IO.StreamReader($fs)
    $totalBytes = $fs.Length
    $lines = 0
    $lastTick = [datetime]::UtcNow

    while (($line = $sr.ReadLine()) -ne $null) {
        $lines++
        if ([string]::IsNullOrWhiteSpace($line)) { continue }

        # Time-based progress update (~every 1 second)
        if (([datetime]::UtcNow - $lastTick).TotalSeconds -ge $ProgressIntervalSeconds) {
            $lastTick = [datetime]::UtcNow
            $pct = if ($totalBytes -gt 0) { [math]::Round(($fs.Position / $totalBytes) * 100, 2) } else { 0 }
            Write-Progress -Activity "Analyzing 3CX CDR..." -Status "Processed $lines lines" -PercentComplete $pct
        }

        # Extract fields (fast path unless quotes present on this line)
        $startStr = $null; $endStr = $null
        if ($hasQuotesInFile -and $line.Contains('"')) {
            $parts = Split-CSVQuotedLight $line
            if ($parts.Count -le [Math]::Max($startIdx,$endIdx)) { continue }
            $startStr = $parts[$startIdx]
            $endStr   = $parts[$endIdx]
        } else {
            $startStr = Get-Field -line $line -index $startIdx
            $endStr   = Get-Field -line $line -index $endIdx
        }

        $start = Parse-Time ($startStr)
        if ($start) {
            if ($startsAt.ContainsKey($start)) { $startsAt[$start] += 1 } else { $startsAt[$start] = 1 }
            if ($start -lt $minStart) { $minStart = $start; $haveStart = $true }
        }

        $end = Parse-Time ($endStr)
        if ($end) {
            if ($endsAt.ContainsKey($end)) { $endsAt[$end] += 1 } else { $endsAt[$end] = 1 }
            if ($end -gt $maxEnd) { $maxEnd = $end; $haveEnd = $true }
        }
    }
}
finally {
    if ($sr) { $sr.Close() }
    if ($fs) { $fs.Close() }
}

# ---------------- SWEEP (STARTS BEFORE ENDS + RELIABLE PEAK TIME) ----------------
$allTs = ($startsAt.Keys + $endsAt.Keys) | ForEach-Object { if ($_ -is [datetime]) { $_ } else { [datetime]$_ } } | Sort-Object -Unique

foreach ($ts in $allTs) {
    if ($startsAt.ContainsKey($ts)) { $currentConcurrent += $startsAt[$ts] }   # starts first
    if ($currentConcurrent -gt $maxConcurrent) {
        $maxConcurrent = $currentConcurrent
        $peakTimestamp = $ts
    }
    if ($endsAt.ContainsKey($ts))   { $currentConcurrent -= $endsAt[$ts] }     # then ends
}

Write-Progress -Activity "Analyzing 3CX CDR..." -Completed -Status "Done!"

# ---------------- OUTPUT ----------------
$fmt = 'yyyy/MM/dd HH:mm:ss'  # display format (choose one style)
$windowStart = if ($haveStart) { $minStart.ToString($fmt) } else { '(unknown)' }
$windowEnd   = if ($haveEnd)   { $maxEnd.ToString($fmt) } else { '(unknown)' }

Write-Host "---------------------------------------------"
Write-Host (" Max concurrent calls : {0}" -f $maxConcurrent)
if ($peakTimestamp -ne [datetime]::MinValue) {
    Write-Host (" Peak first occurred  : {0}" -f $peakTimestamp.ToString($fmt))
} else {
    Write-Host " Peak first occurred  : (not found)"
}
Write-Host (" Time window analyzed  : {0}  ->  {1}" -f $windowStart, $windowEnd)
Write-Host (" Lines processed       : {0}" -f $lines)
Write-Host "---------------------------------------------"
 
Last edited:

Forum statistics

Threads
111,955
Messages
589,925
Members
164,853
Latest member
as7h