In this article, you will learn how to monitor macOS performance data with shell scripting. In the world of IT operations and endpoint management, understanding a system’s performance is not optional—it’s mission-critical. From ensuring that enterprise Mac fleets remain healthy to troubleshooting user complaints with real metrics, the need for transparent, reliable performance data is more pressing than ever. For IT professionals and Managed Service Providers (MSPs), a proactive approach to performance monitoring can improve service quality, reduce downtime, and enable data-driven decision-making. This is where automated shell scripts shine.
Background
While tools like Activity Monitor or third-party performance dashboards provide useful insights, they often lack automation, consistency, and integration with centralized management platforms like NinjaOne. The script we’re examining fills that gap by collecting a wide array of macOS system performance data—CPU, memory, disk, and network usage—then optionally formatting it into HTML and storing it in a NinjaOne WYSIWYG custom field.
This approach eliminates manual diagnostics, standardizes performance reports, and makes metrics visible to technicians directly in the endpoint record. It’s tailored to support zero-touch monitoring workflows, remote troubleshooting, and executive-level reporting.
The Script:
#!/usr/bin/env bash
#
# Description: Collects system performance data (CPU, memory, disk, and network). The results can optionally be saved to a WYSIWYG custom field.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms.
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party.
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider.
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations.
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks.
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script.
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Example: --daysSinceLastReboot "1" --durationToPerformTests "5" --numberOfEvents "5" --wysiwygCustomFieldName "WYSIWYG"
#
# [Alert] This computer was last started on 02/24/25 08:24:27 AM, which was 7.30 days ago.
# Collecting the last 5 error events from the system log.
#
# Collecting performance metrics for 5 minutes.
# Finished collecting performance metrics.
#
# Formatting total CPU results.
# Formatting CPU process data.
# Formatting the total RAM results.
# Formatting RAM process data.
# Formatting the network usage data.
# Formatting disk results.
# Formatting event log entries.
#
# Collecting system information.
#
# ### Apple M1 ###
# Average CPU % Used Minimum CPU % Used Maximum CPU % Used
# 48.07% 40.30% 67.37%
#
# ### Memory Usage ###
# Total Memory Installed: 16.00 GiB
# Average RAM % Used Minimum RAM % Used Maximum RAM % Used
# 99.52% 99.44% 99.59%
#
# ### Top 5 CPU Processes ###
# PID Name Average CPU % Used Minimum CPU % Used Maximum CPU % Used
# 656 WindowServer 21.57% 5.90% 36.90%
# 3503 firefox 18.05% 6.00% 30.50%
# 74524 Storage 8.95% 8.80% 12.70%
# 0 kernel_task 5.78% 5.40% 8.00%
# 74563 ApplicationsStor 5.02% 3.50% 10.40%
#
# ### Top 5 RAM Processes ###
# PID Name Average RAM % Used Minimum RAM % Used Maximum RAM % Used
# 3503 firefox 8.43% 8.33% 8.58%
# 72329 datagrip 7.96% 7.95% 7.96%
# 656 WindowServer 4.27% 4.26% 4.28%
# 40463 plugin-container 3.87% 3.87% 3.89%
# 828 ninjarmm-macagen 3.28% 3.27% 3.28%
#
# ### Network Usage ###
# Network Adapter MAC Address Type Average Sent & Received Minimum Sent & Received Maximum Sent & Received
# en0 00:00:00:00:00:00 Wi-Fi 7.98 Mbps 7.91 Mbps 8.03 Mbps
#
# ### Disk Usage ###
# Volume: disk1s1
# Name: iSCPreboot
# Mount Point: /System/Volumes/iSCPreboot
# Consumed Space: 5.55 MiB (1.11%)
# Container: disk1
# Free Space: 479.16 MiB
# Total Space: 500 MiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk1s2
# Name: xART
# Mount Point: /System/Volumes/xarts
# Consumed Space: 6.02 MiB (1.20%)
# Container: disk1
# Free Space: 479.16 MiB
# Total Space: 500 MiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk1s3
# Name: Hardware
# Mount Point: /System/Volumes/Hardware
# Consumed Space: 4.52 MiB (0.90%)
# Container: disk1
# Free Space: 479.16 MiB
# Total Space: 500 MiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk1s4
# Name: Recovery
# Mount Point: N/A
# Consumed Space: 20 KiB (0%)
# Container: disk1
# Free Space: 479.16 MiB
# Total Space: 500 MiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk2s1
# Name: Recovery
# Mount Point: N/A
# Consumed Space: 1.84 GiB (36.87%)
# Container: disk2
# Free Space: 3.14 GiB
# Total Space: 5 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk2s2
# Name: Update
# Mount Point: N/A
# Consumed Space: 2.84 MiB (0.06%)
# Container: disk2
# Free Space: 3.14 GiB
# Total Space: 5 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s1
# Name: Macintosh HD - Data
# Mount Point: /System/Volumes/Data
# Consumed Space: 156.20 GiB (68.43%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s2
# Name: Update
# Mount Point: /System/Volumes/Update
# Consumed Space: 62.33 MiB (0.03%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s3
# Name: Macintosh HD
# Mount Point: N/A
# Consumed Space: 10.48 GiB (4.59%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s4
# Name: Preboot
# Mount Point: /System/Volumes/Preboot
# Consumed Space: 6.73 GiB (2.95%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s5
# Name: Recovery
# Mount Point: N/A
# Consumed Space: 998.96 MiB (0.43%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# Volume: disk3s6
# Name: VM
# Mount Point: /System/Volumes/VM
# Consumed Space: 1 GiB (0.44%)
# Container: disk3
# Free Space: 52.70 GiB
# Total Space: 228.27 GiB
# Physical Disk: APPLE SSD AP0256Q
# Media Type: SSD
#
# The custom field 'WYSIWYG' has been provided.
# Converting CPU process data into HTML.
# Converting RAM process data into HTML.
# Converting network usage data into HTML.
# Converting disk usage data into HTML.
# Assembling the html data into the html card
# Highlighting last startup date.
# Highlighting overall CPU usage metrics.
# Converting system log data into an HTML format.
#
# Attempting to set the custom field 'WYSIWYG'.
# Successfully set the custom field 'WYSIWYG'.
#
# ### Last 5 warnings and errors in the system log. ###
# Service: trustd
# PID: 1656
# Time Created: 03/03/25 03:37:58 PM
# Message: (Security) [com.apple.security:seckey] SecKeyVerifySignature failed: Error Domain=NSOSStatusErrorDomain Code=-67808 "RSA signature verification failed, no match" UserInfo={numberOfErrorsDeep=0, NSDescription=RSA signature verification failed, no match}
#
# Service: trustd
# PID: 1656
# Time Created: 03/03/25 03:37:58 PM
# Message: (Security) [com.apple.security:seckey] SecKeyVerifySignature failed: Error Domain=NSOSStatusErrorDomain Code=-67808 "RSA signature verification failed, no match" UserInfo={numberOfErrorsDeep=0, NSDescription=RSA signature verification failed, no match}
#
# Service: trustd
# PID: 1656
# Time Created: 03/03/25 03:37:58 PM
# Message: (Security) [com.apple.security:seckey] SecKeyVerifySignature failed: Error Domain=NSOSStatusErrorDomain Code=-67808 "RSA signature verification failed, no match" UserInfo={numberOfErrorsDeep=0, NSDescription=RSA signature verification failed, no match}
#
# Service: trustd
# PID: 1656
# Time Created: 03/03/25 03:37:58 PM
# Message: (Security) [com.apple.security:seckey] SecKeyVerifySignature failed: Error Domain=NSOSStatusErrorDomain Code=-67808 "RSA signature verification failed, no match" UserInfo={numberOfErrorsDeep=0, NSDescription=RSA signature verification failed, no match}
#
# Service: trustd
# PID: 1656
# Time Created: 03/03/25 03:37:58 PM
# Message: (Security) [com.apple.security:seckey] SecKeyVerifySignature failed: Error Domain=NSOSStatusErrorDomain Code=-67808 "RSA signature verification failed, no match" UserInfo={numberOfErrorsDeep=0, NSDescription=RSA signature verification failed, no match}
#
# Preset Parameter: --daysSinceLastReboot "7"
# Specify the number of days by which the system should have been rebooted.
#
# Preset Parameter: --durationToPerformTests "5"
# The duration (in minutes) for which the performance tests should be executed.
#
# Preset Parameter: --numberOfEvents "5"
# The number of error events to retrieve from the system log.
#
# Preset Parameter: --wysiwygCustomField "ReplaceMeWithAnyWYSIWYGCustomField"
# Optionally specify the name of a WYSIWYG custom field to store the formatted performance data.
#
# Preset Parameter: --help
# Displays some help text.
#
# Minimum Supported OS: macOS Ventura
# Release Notes: Initial Release
# Script arguments
_arg_daysSinceLastReboot=
_arg_durationToPerformTests=5
_arg_numberOfEvents=5
_arg_wysiwygCustomField=
exitCode=0
# Converts a string input into an HTML table format.
convertToHTMLTable() {
local _arg_delimiter=" "
local _arg_inputObject
# Process command-line arguments for the function.
while test $# -gt 0; do
_key="$1"
case "$_key" in
--delimiter | -d)
test $# -lt 2 && echo "[Error] Missing value for the required argument" >&2 && return 1
_arg_delimiter=$2
shift
;;
--*)
echo "[Error] Got an unexpected argument" >&2
return 1
;;
*)
_arg_inputObject=$1
;;
esac
shift
done
# Handles missing input by checking stdin or returning an error.
if [[ -z $_arg_inputObject ]]; then
if [ -p /dev/stdin ]; then
_arg_inputObject=$(cat)
else
echo "[Error] Missing input object to convert to table" >&2
return 1
fi
fi
_arg_inputObject=${_arg_inputObject//"%"/"%%"}
local htmlTable="<table>\n"
htmlTable+=$(printf '%b' "$_arg_inputObject" 2> /dev/null | head -n1 | awk -F "$_arg_delimiter" '{
printf "<tr>"
for (i=1; i<=NF; i+=1)
{ printf "<th>"$i"</th>" }
printf "</tr>"
}')
htmlTable+="\n"
htmlTable+=$(printf '%b' "$_arg_inputObject" | tail -n +2 | awk -F "$_arg_delimiter" '{
printf "<tr>"
for (i=1; i<=NF; i+=1)
{ printf "<td>"$i"</td>" }
print "</tr>"
}')
htmlTable+="\n</table>"
printf '%b' "$htmlTable" '\n'
}
ConvertTo-FriendlySize() {
local size=$1
local units=(B KiB MiB GiB TiB PiB)
local i=0
while (($(echo "$size >= 1024" | bc -l) && i < ${#units[@]} - 1)); do
size=$(echo "scale=6; $size/1024" | bc)
((i++))
done
# Round to two decimal places
size=$(printf "%.2f" "$size")
# Remove trailing .00 if present
size=${size//.00/}
echo "${size} ${units[i]}"
}
# Help menu
print_help() {
printf '\n\n%s\n\n' 'Usage: [--daysSinceLastReboot|-r <arg>] [--durationToPerformTests|-d <arg>] [--numberOfEvents|-n <arg>]
[--wysiwygCustomField|-w <arg>] [--help|-h]'
printf '%s\n' 'Preset Parameter: --daysSinceLastReboot "7"'
printf '\t%s\n' "Specify the number of days by which the system should have been rebooted."
printf '%s\n' 'Preset Parameter: --durationToPerformTests "5"'
printf '\t%s\n' "The duration (in minutes) for which the performance tests should be executed."
printf '%s\n' 'Preset Parameter: --numberOfEvents "5"'
printf '\t%s\n' "The number of error events to retrieve from the system log."
printf '%s\n' 'Preset Parameter: --wysiwygCustomField "ReplaceMeWithAnyWYSIWYGCustomField"'
printf '\t%s\n' "Optionally specify the name of a WYSIWYG custom field to store the formatted performance data."
printf '%s\n' 'Preset Parameter: --help'
printf '\t%s\n' "Displays this help menu."
}
die() {
local _ret="${2:-1}"
echo "$1" >&2
test "${_PRINT_HELP:-no}" = yes && print_help >&2
exit "${_ret}"
}
# Parses the given command line parameters
parse_commandline() {
while test $# -gt 0; do
_key="$1"
case "$_key" in
--daysSinceLastReboot | --dayssincelastreboot | --days | -r)
test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
_arg_daysSinceLastReboot=$2
shift
;;
--daysSinceLastReboot=*)
_arg_daysSinceLastReboot="${_key##--daysSinceLastReboot=}"
;;
--durationToPerformTests | --durationtoperformtests | --duration | -d)
test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
_arg_durationToPerformTests=$2
shift
;;
--durationToPerformTests=*)
_arg_durationToPerformTests="${_key##--durationToPerformTests=}"
;;
--numberOfEvents | --numberofevents | --events | -n)
test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
_arg_numberOfEvents=$2
shift
;;
--numberOfEvents=*)
_arg_numberOfEvents="${_key##--numberOfEvents=}"
;;
--wysiwygCustomField | --wysiwygcustomfield | --wysiwyg | -w)
test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
_arg_wysiwygCustomField=$2
shift
;;
--wysiwygCustomField=*)
_arg_wysiwygCustomField="${_key##--wysiwygCustomField=}"
;;
--help | -h)
_PRINT_HELP=yes die
;;
*)
_PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
;;
esac
shift
done
}
echo " "
parse_commandline "$@"
# If script form variables are used, replace the command line parameters with their value.
if [[ -n $daysSinceLastReboot ]]; then
_arg_daysSinceLastReboot="$daysSinceLastReboot"
fi
if [[ -n $durationToPerformTests ]]; then
_arg_durationToPerformTests="$durationToPerformTests"
fi
if [[ -n $numberOfEvents ]]; then
_arg_numberOfEvents="$numberOfEvents"
fi
if [[ -n $wysiwygCustomFieldName ]]; then
_arg_wysiwygCustomField="$wysiwygCustomFieldName"
fi
# Ensure the script is being run with root permissions
if [[ $(id -u) -ne 0 ]]; then
_PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi
# Validate and sanitize the 'Days Since Last Reboot' argument
if [[ -n "$_arg_daysSinceLastReboot" ]]; then
_arg_daysSinceLastReboot=$(echo "$_arg_daysSinceLastReboot" | xargs)
# Check if the argument is empty after trimming
if [[ -z "$_arg_daysSinceLastReboot" ]]; then
_PRINT_HELP=yes die "[Error] The 'Days Since Last Reboot' value of '$_arg_daysSinceLastReboot' is invalid. Please provide a positive whole number or 0."
fi
fi
# Ensure 'Days Since Last Reboot' is a valid numeric value
if [[ -n "$_arg_daysSinceLastReboot" && "$_arg_daysSinceLastReboot" =~ [^0-9] ]]; then
_PRINT_HELP=yes die "[Error] The 'Days Since Last Reboot' value of '$_arg_daysSinceLastReboot' is invalid. Please provide a positive whole number or 0." 1
fi
# Validate and sanitize the 'Duration To Perform Tests' argument
if [[ -n "$_arg_durationToPerformTests" ]]; then
_arg_durationToPerformTests=$(echo "$_arg_durationToPerformTests" | xargs)
fi
# Ensure the duration is provided
if [[ -z "$_arg_durationToPerformTests" ]]; then
_PRINT_HELP=yes die "[Error] Please provide a valid duration for performing the tests." 1
fi
# Check if the duration is a valid positive number between 1 and 60
if [[ "$_arg_durationToPerformTests" =~ [^0-9] || "$_arg_durationToPerformTests" -eq 0 || "$_arg_durationToPerformTests" -ge 60 ]]; then
_PRINT_HELP=yes die "[Error] The 'Duration To Perform Tests' value of '$_arg_durationToPerformTests' is invalid.
[Error] Please provide a positive whole number that's greater than 0 and less than 60." 1
fi
# Validate and sanitize the 'Number of Events' argument
if [[ -n "$_arg_numberOfEvents" ]]; then
_arg_numberOfEvents=$(echo "$_arg_numberOfEvents" | xargs)
fi
# Ensure the number of events is provided
if [[ -z "$_arg_numberOfEvents" ]]; then
_PRINT_HELP=yes die "[Error] Please provide a valid number of recent error events to include in the results." 1
fi
# Check if the number of events is a valid numeric value and within the allowed range
if [[ "$_arg_numberOfEvents" =~ [^0-9] || "$_arg_numberOfEvents" -gt 10000 ]]; then
_PRINT_HELP=yes die "[Error] The 'Number of Events' value of '$_arg_numberOfEvents' is invalid. Please provide a positive whole number that is less than or equal to 10000 or greater than or equal to 0." 1
fi
# Validate and sanitize the 'WYSIWYG Custom Field' argument
if [[ -n "$_arg_wysiwygCustomField" ]]; then
_arg_wysiwygCustomField=$(echo "$_arg_wysiwygCustomField" | xargs)
# Check if the argument is empty
if [[ -z "$_arg_wysiwygCustomField" ]]; then
_PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field' value of '$_arg_wysiwygCustomField' is invalid. Please provide a valid custom field name.
https://ninjarmm.zendesk.com/hc/en-us/articles/360060920631-Custom-Field-Setup" 1
fi
fi
# Check if the custom field contains invalid characters
if [[ -n "$_arg_wysiwygCustomField" && "$_arg_wysiwygCustomField" =~ [^0-9a-zA-Z0-9] ]]; then
_PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field' value of '$_arg_wysiwygCustomField' is invalid. Please provide a valid custom field name.
https://ninjarmm.zendesk.com/hc/en-us/articles/360060920631-Custom-Field-Setup" 1
fi
# Locate the ninjarmm-cli tool, which is required for setting custom fields
if [[ -n "$_arg_wysiwygCustomField" ]]; then
if [[ -n $NINJA_DATA_PATH && -f "$NINJA_DATA_PATH/ninjarmm-cli" ]]; then
ninjarmmcliPath="$NINJA_DATA_PATH/ninjarmm-cli"
elif [[ -f "/Applications/NinjaRMMAgent/programdata/ninjarmm-cli" ]]; then
ninjarmmcliPath=/Applications/NinjaRMMAgent/programdata/ninjarmm-cli
else
_PRINT_HELP=no die "[Error] Unable to locate ninjarmm-cli. Ninjarmm-cli is required to set custom fields.
https://ninjarmm.zendesk.com/hc/en-us/articles/4405408656013-Custom-Fields-and-Documentation-CLI-and-Scripting#h_01FB4NZ6GCG2V53NS4T1RQX8PY" 1
fi
fi
# Ensure the script does not run if another instance is already running
lockFile=/Applications/NinjaRMMAgent/SystemPerformance.lock
if [[ -f "$lockFile" ]]; then
echo "Process lock file found at '$lockFile'. Checking if the process is still running."
if ! otherScript=$(cat "$lockFile"); then
_PRINT_HELP=no die "[Error] Unable to access the lock file at '$lockFile'." 1
fi
if ! scriptProcess=$(ps -p "$otherScript" | sed '1d'); then
_PRINT_HELP=no die "[Error] Unable to verify if another copy of the script is currently running with the process id (PID)." 1
fi
if [[ -n "$scriptProcess" ]]; then
_PRINT_HELP=no die "[Error] This script is already running in another process with the process id (PID) '$otherScript'. Please wait for that process to complete." 1
fi
fi
# Create a lock file to prevent multiple script instances
if ! echo $$ > $lockFile; then
_PRINT_HELP=no die "[Error] Failed to create the process lock file at '$lockFile'. This is to prevent multiple instances of the performance script from running simultaneously." 1
fi
# Record the start date and time of the script
reportStartDate=$(date "+%x %r")
# Calculate the system's last boot time and check if it exceeds the allowed days since reboot
lastBoot=$(sysctl -n kern.boottime | awk '{ print $4 }' | sed "s/[^0-9]//g")
lastStartupDate=$(date -r "$lastBoot" "+%x %r")
if [[ -n "$_arg_daysSinceLastReboot" && $(date -v "-$_arg_daysSinceLastReboot"d "+%s") -gt "$lastBoot" ]]; then
secondsSinceLastBoot=$(($(date "+%s") - lastBoot))
daysSinceLastBoot=$(echo "$secondsSinceLastBoot" | awk '{printf "%.2f\n", $1 / 86400}')
echo "[Alert] This computer was last started on $lastStartupDate, which was $daysSinceLastBoot days ago."
exceededLastStartupLimit="true"
fi
# Collect error events from the system logs
if [[ "$_arg_numberOfEvents" -gt 0 ]]; then
echo "Collecting the last $_arg_numberOfEvents error events from the system log."
predicateFilter='(messageType == "error")'
log show --last boot --predicate "$predicateFilter" --style "syslog" | tail -n "$_arg_numberOfEvents" > /tmp/syslog.log 2> /tmp/syslogErr.log &
fi
# Collect CPU, RAM, network, and disk performance metrics
echo ""
echo "Collecting performance metrics for $_arg_durationToPerformTests minutes."
diskutil apfs list -plist > /tmp/diskData.log 2> /tmp/diskDataErr.log
memory_pressure > /tmp/totalMemData.log 2> /tmp/totalMemDataErr.log
COLUMNS=10000 top -l "$((_arg_durationToPerformTests + 1))" -s 60 -o cpu -stats pid,cpu,command > /tmp/cpuData.log 2> /tmp/cpuDataErr.log &
COLUMNS=10000 top -l "$((_arg_durationToPerformTests + 1))" -s 60 -o mem -stats pid,mem,command > /tmp/memData.log 2> /tmp/memDataErr.log &
nettop -t external -x -L "$((_arg_durationToPerformTests + 1))" -s 60 > /tmp/netData.log 2> /tmp/netDataErr.log &
i=1
while ((i < "$_arg_durationToPerformTests")); do
memory_pressure >> /tmp/totalMemData.log 2>> /tmp/totalMemDataErr.log
sleep 60
((i++))
done
# Wait for background tasks to complete
wait
# Validate collected event logs and handle errors
if [[ "$_arg_numberOfEvents" -gt 0 ]]; then
if [[ -s "/tmp/syslogErr.log" ]]; then
cat "/tmp/syslogErr.log" >&2
echo "[Error] Errors were detected while attempting to retrieve the last $_arg_numberOfEvents events." >&2
exitCode=1
fi
if [[ -f "/tmp/syslog.log" ]]; then
syslogLines=$(wc -l /tmp/syslog.log | grep -o "[0-9]*" | xargs)
fi
if [[ -z "$syslogLines" || "$syslogLines" =~ [^0-9] || "$syslogLines" -le 1 ]]; then
echo "[Warning] No errors were found in the system log since the last boot. Is the system log service enabled?"
fi
fi
# Validate collected performance data and handle errors
if [[ -s "/tmp/diskDataErr.log" ]]; then
cat "/tmp/diskDataErr.log"
echo "[Warning] Errors were detected while attempting to gather disk performance metrics."
fi
if [[ ! -s "/tmp/diskData.log" ]]; then
_PRINT_HELP=no die "[Error] No disk performance metrics were collected." 1
fi
if [[ -s "/tmp/cpuDataErr.log" ]]; then
cat "/tmp/cpuDataErr.log" >&2
_PRINT_HELP=no die "[Error] Errors were detected while attempting to gather CPU performance metrics." 1
fi
if [[ ! -s "/tmp/cpuData.log" ]]; then
_PRINT_HELP=no die "[Error] No cpu performance metrics were collected." 1
fi
if [[ -s "/tmp/memDataErr.log" ]]; then
cat "/tmp/memDataErr.log" >&2
_PRINT_HELP=no die "[Error] Errors were detected while attempting to gather RAM performance metrics." 1
fi
if [[ ! -s "/tmp/memData.log" ]]; then
_PRINT_HELP=no die "[Error] No RAM performance metrics were collected." 1
fi
if [[ -s "/tmp/totalMemDataErr.log" ]]; then
cat "/tmp/totalMemDataErr.log" >&2
_PRINT_HELP=no die "[Error] Errors were detected while attempting to gather RAM performance metrics." 1
fi
if [[ ! -s "/tmp/totalMemData.log" ]]; then
_PRINT_HELP=no die "[Error] No RAM performance metrics were collected." 1
fi
if [[ -s "/tmp/netDataErr.log" ]]; then
cat "/tmp/netDataErr.log" >&2
_PRINT_HELP=no die "[Error] Errors were detected while attempting to gather network performance metrics." 1
fi
if [[ ! -s "/tmp/netData.log" ]]; then
_PRINT_HELP=no die "[Error] No network performance metrics were collected." 1
fi
# Indicate the completion of performance metric collection
echo "Finished collecting performance metrics."
echo ""
# Process and format the total CPU usage metrics
echo "Formatting total CPU results."
cpuTotalData=$(
awk '
BEGIN {
# Define the table header for CPU results
header = "Average CPU % Used;|;Minimum CPU % Used;|;Maximum CPU % Used"
}
/^[[:space:]]*CPU usage:/{
# Extract CPU idle percentage and calculate the used percentage
if ($7 ~ /[0-9]/) {
split($7, cpuIdlePerc,"%")
cpuPerc = 100 - $7
}
# Aggregate metrics
countCpu += 1
cpuSum += cpuPerc
# Determine minimum and maximum CPU usage
if(! cpuMin ){ cpuMin = cpuPerc }
if(cpuPerc < cpuMin){ cpuMin = cpuPerc }
if(! cpuMax){ cpuMax = cpuPerc }
if(cpuPerc > cpuMax){ cpuMax = cpuPerc }
}
END {
# Print the formatted CPU usage data
print header
avgCpu = cpuSum / countCpu
printf "%.2f%%;|;%.2f%%;|;%.2f%%\n", avgCpu, cpuMin, cpuMax
}
' '/tmp/cpuData.log'
)
# Extract specific CPU metrics (average, minimum, and maximum) from the formatted data
CPUAverage=$(echo "$cpuTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $1 }')
CPUMinimum=$(echo "$cpuTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $2 }')
CPUMaximum=$(echo "$cpuTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $3 }')
# Process and format CPU usage by process
echo "Formatting CPU process data."
cpuProcessData=$(
awk '
BEGIN {
# Define the table header for process-level CPU metrics
header = "PID;|;Name;|;Average CPU % Used;|;Minimum CPU % Used;|;Maximum CPU % Used"
}
/^[[:space:]]*[0-9]/ && NF >= 3{
# Extract process information (PID, name, and CPU usage)
procId = $1
gsub(/[^0-9]/, "", procId)
cpuPerc = $2
procName = ""
for (i = 3; i <= NF; i++) {
procName = procName $i " "
}
gsub(/^[ \t\r\n]+/, "", procName)
gsub(/[ \t\r\n]+$/, "", procName)
if (procName == "nettop" || procName == "top" || procName == "log") {
next
}
# Aggregate CPU metrics for each process
sumCpu[procId] += cpuPerc
countCpu[procId] += 1
pidName[procId] = procName
# Determine minimum and maximum CPU usage for each process
if(! cpuMin[procId] ){ cpuMin[procId] = cpuPerc }
if(cpuPerc < cpuMin[procId]){ cpuMin[procId] = cpuPerc }
if(! cpuMax[procId]){ cpuMax[procId] = cpuPerc }
if(cpuPerc > cpuMax[procId]){ cpuMax[procId] = cpuPerc }
}
END {
# Print the formatted process-level CPU data
print header
for (pid in sumCpu) {
avgCpu = sumCpu[pid] / countCpu[pid]
printf "%s;|;%s;|;%.2f%%;|;%.2f%%;|;%.2f%%\n", pid, pidName[pid], avgCpu, cpuMin[pid], cpuMax[pid]
}
}
' '/tmp/cpuData.log' | {
# Sort and display the top 6 processes by average CPU usage
read -r header
echo "$header"
sed 's/;|;/\//g' |
sort -t '/' -k3,3nr |
sed 's/\//;|;/g'
} | head -n 6
)
# Process and format the total RAM usage metrics
echo "Formatting the total RAM results."
memTotalData=$(
awk '
BEGIN {
# Define the table header for RAM results
header = "Average RAM % Used;|;Minimum RAM % Used;|;Maximum RAM % Used"
}
/^[[:space:]]*System-wide/ {
# Compute the percentage of RAM used
gsub(/%/, "", $5)
memPerc = 100 - $5
# Aggregate metrics
memSum += memPerc
memCount += 1
# Determine minimum and maximum RAM usage
if(! memMin ){ memMin = memPerc }
if(memPerc < memMin){ memMin = memPerc }
if(! memMax){ memMax = memPerc }
if(memPerc > memMax){ memMax = memPerc }
}
END {
# Print the formatted RAM usage data
print header
avgMem = memSum / memCount
printf "%.2f%%;|;%.2f%%;|;%.2f%%\n", avgMem, memMin, memMax
}
' '/tmp/totalMemData.log'
)
# Extract specific RAM metrics (average, minimum, and maximum) from the formatted data
MEMAverage=$(echo "$memTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $1 }')
MEMMinimum=$(echo "$memTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $2 }')
MEMMaximum=$(echo "$memTotalData" | column -t -s ";|;" | grep "[0-9]" | awk '{ print $3 }')
# Process and format RAM usage by process
echo "Formatting RAM process data."
memProcessData=$(
awk '
BEGIN {
# Define the table header for process-level RAM metrics
header = "PID;|;Name;|;Average RAM % Used;|;Minimum RAM % Used;|;Maximum RAM % Used"
}
/^[[:space:]]*PhysMem:/ {
# Extract total and free memory and calculate used percentage
memFreeHumanFriendly = $8
memUsedHumanFriendly = $2
# Split the free memory string to isolate the numeric part (ignoring non-digits).
split(memFreeHumanFriendly, memFreeNumber,/[^0-9]/)
# Similarly, split the used memory string.
split(memUsedHumanFriendly, memUsedNumber,/[^0-9]/)
# Convert the free memory value to bytes based on the unit (T, G, M, or K).
if(memFreeHumanFriendly ~ /T/){
memFreeBytes = memFreeNumber[1] * 1000000000000
}
if(memFreeHumanFriendly ~ /G/){
memFreeBytes = memFreeNumber[1] * 1000000000
}
if(memFreeHumanFriendly ~ /M/){
memFreeBytes = memFreeNumber[1] * 1000000
}
if(memFreeHumanFriendly ~ /K/){
memFreeBytes = memFreeNumber[1] * 1000
}
# Convert the used memory value to bytes based on the unit (T, G, M, or K).
if(memUsedHumanFriendly ~ /T/){
memUsedBytes = memUsedNumber[1] * 1000000000000
}
if(memUsedHumanFriendly ~ /G/){
memUsedBytes = memUsedNumber[1] * 1000000000
}
if(memUsedHumanFriendly ~ /M/){
memUsedBytes = memUsedNumber[1] * 1000000
}
if(memUsedHumanFriendly ~ /K/){
memUsedBytes = memUsedNumber[1] * 1000
}
# Calculate the total system memory in bytes.
memTotal = memUsedBytes + memFreeBytes
}
/^[[:space:]]*[0-9]/ && NF >= 3 {
# Extract the process ID (PID) from the first field and remove any non-digit characters.
procId = $1
gsub(/[^0-9]/, "", procId)
# Concatenate fields 3 to NF to build the process name.
procName = ""
for (i = 3; i <= NF; i++) {
procName = procName $i " "
}
# Trim leading and trailing whitespace from the process name.
gsub(/^[ \t\r\n]+/, "", procName)
gsub(/[ \t\r\n]+$/, "", procName)
# Skip processing for specific process names that are not relevant.
if (procName == "nettop" || procName == "top" || procName == "log") {
next
}
# Extract the process-specific used memory (in a human-friendly format) from the second field.
memProcessUsedHumanFriendly = $2
# Split the process used memory string to extract the numeric part.
split(memProcessUsedHumanFriendly, memProcessUsedNumber,/[^0-9]/)
# Convert the process used memory value to bytes based on its unit.
if(memProcessUsedHumanFriendly ~ /T/){
memProcessUsedBytes = memProcessUsedNumber[1] * 1000000000000
}
if(memProcessUsedHumanFriendly ~ /G/){
memProcessUsedBytes = memProcessUsedNumber[1] * 1000000000
}
if(memProcessUsedHumanFriendly ~ /M/){
memProcessUsedBytes = memProcessUsedNumber[1] * 1000000
}
if(memProcessUsedHumanFriendly ~ /K/){
memProcessUsedBytes = memProcessUsedNumber[1] * 1000
}
# Calculate the percentage of system RAM used by this process.
ramPerc = ( memProcessUsedBytes / memTotal ) * 100
# Aggregate RAM metrics for each process
sumRam[procId] += ramPerc
countRam[procId] += 1
pidName[procId] = procName
# Determine minimum and maximum RAM usage for each process
if(! ramMin[procId] ){ ramMin[procId] = ramPerc }
if(ramPerc < ramMin[procId]){ ramMin[procId] = ramPerc }
if(! ramMax[procId]){ ramMax[procId] = ramPerc }
if(ramPerc > ramMax[procId]){ ramMax[procId] = ramPerc }
}
END {
# Print the formatted process-level RAM data
print header
for (pid in sumRam) {
avgRam = sumRam[pid] / countRam[pid]
printf "%s;|;%s;|;%.2f%%;|;%.2f%%;|;%.2f%%\n", pid, pidName[pid], avgRam, ramMin[pid], ramMax[pid]
}
}
' '/tmp/memData.log' | {
# Sort and display the top 6 processes by average RAM usage
read -r header
echo "$header"
sed 's/;|;/\//g' |
sort -t '/' -k3,3nr |
sed 's/\//;|;/g'
} | head -n 6
)
# Process and format network usage metrics
echo "Formatting the network usage data."
netData=$(
awk -F "," '
BEGIN {
# Define the table header for network usage metrics
header = "Network Adapter;|;MAC Address;|;Type;|;Average Sent & Received;|;Minimum Sent & Received;|;Maximum Sent & Received"
# Set SUBSEP to "|" for multidimensional array keys.
SUBSEP = "|"
}
# Process only lines that have a non-empty 3rd field and start with a digit (ignoring leading spaces)
length($3) > 0 && /^[[:space:]]*[0-9]/{
netAdapter = $3
receivedBytes = $5
transmittedBytes = $6
# Split the first field (time) using ":" as a delimiter.
split($1, time, ":")
# Skip the loopback interface "lo0".
if (netAdapter == "lo0"){
next
}
# Calculate total bits transferred: add received and transmitted bytes, then multiply by 8.
totalBits = (receivedBytes + transmittedBytes) * 8
# Convert total bits to megabits.
totalMegabits = totalBits / 1000000
# Aggregate metrics
total[netAdapter,time[2]] += totalMegabits
adapterName[netAdapter,time[2]] = netAdapter
}
END {
# Print the formatted network usage data
print header
# Loop over each unique composite key from the adapterName array.
for (adapter in adapterName) {
# Map the composite key to the adapter name for aggregation.
name[adapterName[adapter]] = adapterName[adapter]
# Calculate the per-second average usage for this time slice by dividing by 60 seconds.
perSecondAvg = (total[adapter] / 60)
# Sum up the per-second averages for each adapter.
netSum[adapterName[adapter]] += perSecondAvg
countAdapter[adapterName[adapter]] +=1
# Get the minimum and maximum
if(! netMin[adapterName[adapter]]){ netMin[adapterName[adapter]] = perSecondAvg }
if(perSecondAvg < netMin[adapterName[adapter]]){ netMin[adapterName[adapter]] = perSecondAvg }
if(! netMax[adapterName[adapter]]){ netMax[adapterName[adapter]] = perSecondAvg }
if(perSecondAvg > netMax[adapterName[adapter]]){ netMax[adapterName[adapter]] = perSecondAvg }
}
# Loop over each aggregated adapter.
for (combinedAdapter in netSum) {
# Run ifconfig command to fetch the MAC address for the adapter.
ifconfig = "ifconfig " name[combinedAdapter]
while ((ifconfig | getline line) > 0) {
# Look for the line containing the "ether" keyword.
if (line ~ /ether/) {
numberOfFields = split(line, field, " ")
# Loop through the fields to find the MAC address following "ether".
for (i = 1; i <= numberOfFields; i++) {
if (field[i] ~ /ether/) {
adapterMac = field[i+1]
}
}
}
}
close(ifconfig)
# Run networksetup command to determine the adapter type.
# It searches for the hardware port corresponding to the adapter name.
networkSetup = "networksetup -listallhardwareports | grep -B 1 \"" name[combinedAdapter] "\" | head -n 1 | sed \"s/Hardware Port: //\""
while ((networkSetup | getline line) > 0) {
# If the line contains "Wi-Fi", set the adapter type accordingly.
if (line ~ /Wi-Fi/){
adapterType = "Wi-Fi"
} else if (line ~ /Ethernet Adapter/) {
adapterType = "Wired"
}else {
adapterType = "Other"
}
}
close(networkSetup)
# Calculate the average network usage for this adapter.
avgNet = netSum[combinedAdapter] / countAdapter[combinedAdapter]
# Print the final results in CSV format:
# Adapter name, MAC address, adapter type, average, minimum, and maximum usage (appended with "Mbps").
printf "%s;|;%s;|;%s;|;%.2f Mbps;|;%.2f Mbps;|;%.2f Mbps\n", name[combinedAdapter], adapterMac, adapterType, avgNet, netMin[combinedAdapter], netMax[combinedAdapter]
}
}
' '/tmp/netData.log'
)
echo "Formatting disk results."
i=0
apfsContainers=$(/usr/libexec/PlistBuddy -c 'print' /tmp/diskData.log | grep "ContainerReference" | sed "s/ContainerReference =//" | xargs)
diskData="Volume;|;Name;|;Mount Point;|;Consumed Space;|;Container;|;Free Space;|;Total Space;|;Physical Disk;|;Media Type"
for container in $apfsContainers; do
apfsVolumes=$(/usr/libexec/PlistBuddy -c "Print ':Containers:$i:Volumes'" /tmp/diskData.log | grep "DeviceIdentifier" | sed "s/DeviceIdentifier =//" | xargs)
containerFreeSpace=$(/usr/libexec/PlistBuddy -c "Print :Containers:$i:CapacityFree" /tmp/diskData.log)
containerTotalSize=$(/usr/libexec/PlistBuddy -c "Print :Containers:$i:CapacityCeiling" /tmp/diskData.log)
diskInfo=$(diskutil info "$container")
physicalDisk=$(echo "$diskInfo" | grep "Device / Media Name:" | sed "s/Device \/ Media Name://g" | xargs)
if echo "$diskInfo" | grep "Removable Media:" | sed "s/Removable Media://g" | grep "Removable"; then
continue
fi
if echo "$diskInfo" | grep "Solid State" | grep "Yes" > /dev/null 2> /dev/null; then
deviceType="SSD"
elif echo "$diskInfo" | grep "Solid State" | grep "No" > /dev/null 2> /dev/null; then
deviceType="HDD"
else
deviceType="Unspecified"
fi
v=0
for volume in $apfsVolumes; do
volumeInfo=$(diskutil info "$volume")
mountPoint=$(echo "$volumeInfo" | grep "Mount Point:" | sed "s/Mount Point://g" | xargs)
volumeName=$(/usr/libexec/PlistBuddy -c "Print ':Containers:$i:Volumes:$v:Name'" /tmp/diskData.log)
consumedSpace=$(/usr/libexec/PlistBuddy -c "Print ':Containers:$i:Volumes:$v:CapacityInUse'" /tmp/diskData.log)
capacityQuota=$(/usr/libexec/PlistBuddy -c "Print ':Containers:$i:Volumes:$v:CapacityQuota'" /tmp/diskData.log)
if [[ "$capacityQuota" == 0 ]]; then
freeSpace="$containerFreeSpace"
totalSpace=$containerTotalSize
else
freeSpace=$((capacityQuota - consumedSpace))
totalSpace=$capacityQuota
fi
if [[ -n "$totalSpace" ]]; then
consumedSpacePercentage=$(echo "scale=6; $consumedSpace / $totalSpace" | bc)
consumedSpacePercentage=$(echo "scale=6; $consumedSpacePercentage * 100" | bc)
consumedSpacePercentage=$(printf "%.2f" "$consumedSpacePercentage")
consumedSpacePercentage=${consumedSpacePercentage//.00/}
totalSpaceInGB=$(ConvertTo-FriendlySize "$totalSpace")
fi
consumedSpaceInGB=$(ConvertTo-FriendlySize "$consumedSpace")
freeSpaceInGB=$(ConvertTo-FriendlySize "$freeSpace")
if [[ -z "$mountPoint" ]]; then
mountPoint="N/A"
fi
diskData+=$'\n'"$volume;|;$volumeName;|;$mountPoint;|;$consumedSpaceInGB ($consumedSpacePercentage%);|;$container;|;$freeSpaceInGB;|;$totalSpaceInGB;|;$physicalDisk;|;$deviceType"
((v++))
done
((i++))
done
# Check if the journal log file exists and is not empty
if [[ -f "/tmp/syslog.log" && -s "/tmp/syslog.log" ]]; then
# Count the number of lines in the journal log file
journalLines=$(wc -l /tmp/syslog.log | grep -o "[0-9]*" | xargs)
# If there are more than one line in the journal log, process the log entries
if [[ "$journalLines" -ge 1 ]]; then
echo "Formatting event log entries."
# Use `awk` to process the journal log and extract relevant fields
errorEvents=$(
awk '
BEGIN {
# Define the header for the formatted output
header = "Service;|;PID;|;Time Created;|;Message"
print header
}
/^[0-9]/ {
time = $2
gsub(/\.[0-9]+/, "", time)
if ( $1 !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}/ ){
next
}
# Convert the timestamp to epoch format
dateCmd = "date -j -f \"%Y-%m-%d %H:%M:%S%z\" \"" $1 " " time "\" \"+%x %r\""
dateCmd | getline date
close(dateCmd)
# Extract the service, PID, and message from the log entry
if ($4 ~ /^.*\[[0-9]*\]:/) {
split($4, servicePart, "[")
service = servicePart[1]
pid = substr(servicePart[2], 1, length(servicePart[2])-2)
split($0, messagePart, "]: ")
message = messagePart[2]
}else{
split($4, servicePart, ":")
service = servicePart[1]
pid = "N/A"
split($0, messagePart, service": ")
message = messagePart[2]
}
# Output the formatted data as a semicolon-separated string
print service ";|;" pid ";|;" date ";|;" message
}
' '/tmp/syslog.log'
)
fi
fi
echo ""
echo "Collecting system information."
# Retrieve the CPU model name
if ! CPU=$(sysctl -n machdep.cpu.brand_string); then
echo "[Error] Failed to retrieve the cpu information." >&2
exitCode=1
fi
# Retrieve the total memory installed on the system in GiB
if ! TotalMemory=$(sysctl -n hw.memsize | awk '{printf "%.2f GiB\n", $1/1024^3}'); then
echo "[Error] Failed to retrieve the RAM information." >&2
exitCode=1
fi
# Define the list of log files to clean up
logPaths=("/tmp/cpuData.log" "/tmp/cpuDataErr.log" "/tmp/diskData.log" "/tmp/diskDataErr.log" "/tmp/syslog.log" "syslogErr.log" "/tmp/memData.log" "/tmp/memDataErr.log" "/tmp/totalMemData.log" "/tmp/totalMemDataErr.log" "/tmp/netData.log" "/tmp/netDataErr.log")
# Iterate over the log files and remove each if it exists
for logFile in "${logPaths[@]}"; do
if [[ -f "$logFile" ]]; then
# Attempt to remove the log file and handle errors if it fails
if ! rm "$logFile"; then
echo "[Error] Failed to remove the log file at '$logFile'." >&2
exitCode=1
fi
fi
done
# Display the CPU information and formatted total CPU usage data
echo ""
echo "### $CPU ###"
echo "$cpuTotalData" | column -t -s ";|;"
# Display the total memory installed and the formatted total memory usage data
echo ""
echo "### Memory Usage ###"
echo "Total Memory Installed: $TotalMemory"
echo "$memTotalData" | column -t -s ";|;"
# Display the top 5 CPU-consuming processes
echo ""
echo "### Top 5 CPU Processes ###"
echo "$cpuProcessData" | column -t -s ";|;"
# Display the top 5 RAM-consuming processes
echo ""
echo "### Top 5 RAM Processes ###"
echo "$memProcessData" | column -t -s ";|;"
# Display the formatted network usage data
echo ""
echo "### Network Usage ###"
echo "$netData" | column -t -s ";|;"
# Display the formatted disk usage data
echo ""
echo "### Disk Usage ###"
# Output disk data as a list instead of a table
echo "$diskData" | tail -n +2 | awk -F';[|];' '
{
print "Volume: " $1
print "Name: " $2
print "Mount Point: " $3
print "Consumed Space: " $4
print "Container: " $5
print "Free Space: " $6
print "Total Space: " $7
print "Physical Disk: " $8
print "Media Type: " $9
print ""
}'
# Check if a custom field has been provided
if [[ -n "$_arg_wysiwygCustomField" ]]; then
echo "The custom field '$_arg_wysiwygCustomField' has been provided."
# Convert CPU process data to HTML format
echo "Converting CPU process data into HTML."
cpuProcessMetricTable=$(echo "$cpuProcessData" | convertToHTMLTable -d ";[|];")
cpuProcessMetricTable=${cpuProcessMetricTable//"<th>"/"<th><b>"}
cpuProcessMetricTable="${cpuProcessMetricTable//<\/th>/</b></th>}"
cpuProcessMetricTable="${cpuProcessMetricTable//<table>/<table><caption style='border-top: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #CAD0D6'><b>Top 5 CPU Processes</b></caption>}"
cpuProcessMetricTable=${cpuProcessMetricTable//"Average CPU % Used"/"<i class='fa-solid fa-arrow-down-up-across-line'></i> Average CPU % Used"}
cpuProcessMetricTable=${cpuProcessMetricTable//"Minimum CPU % Used"/"<i class='fa-solid fa-arrows-down-to-line'></i> Minimum CPU % Used"}
cpuProcessMetricTable=${cpuProcessMetricTable//"Maximum CPU % Used"/"<i class='fa-solid fa-arrows-up-to-line'></i> Maximum CPU % Used"}
# Apply thresholds for CPU metrics to highlight warnings and danger levels
cpuProcessMetricTable=$(
echo "$cpuProcessMetricTable" | awk '
BEGIN {
dangerThreshold = 50.00
warningThreshold = 20.00
}
{
if ($0 ~ /<tr><td>/) {
split($0, tableData, "<td>")
avg = tableData[4]
min = tableData[5]
max = tableData[6]
gsub(/[^0-9.]/, "", avg)
gsub(/[^0-9.]/, "", min)
gsub(/[^0-9.]/, "", max)
avg += 0
min += 0
max += 0
# Highlight rows based on thresholds
if (avg > dangerThreshold || min > dangerThreshold || max > dangerThreshold) {
sub(/<tr>/, "<tr class='\''danger'\''>");
} else if (avg > warningThreshold || min > warningThreshold || max > warningThreshold) {
sub(/<tr>/, "<tr class='\''warning'\''>");
}
}
print $0
}
'
)
# Convert RAM process data to HTML format
echo "Converting RAM process data into HTML."
ramProcessMetricTable=$(echo "$memProcessData" | convertToHTMLTable -d ";[|];")
ramProcessMetricTable=${ramProcessMetricTable//"<th>"/"<th><b>"}
ramProcessMetricTable="${ramProcessMetricTable//<\/th>/</b></th>}"
ramProcessMetricTable="${ramProcessMetricTable//<table>/<table><caption style='border-top: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #CAD0D6'><b>Top 5 RAM Processes</b></caption>}"
ramProcessMetricTable=${ramProcessMetricTable//"Average RAM % Used"/"<i class='fa-solid fa-arrow-down-up-across-line'></i> Average RAM % Used"}
ramProcessMetricTable=${ramProcessMetricTable//"Minimum RAM % Used"/"<i class='fa-solid fa-arrows-down-to-line'></i> Minimum RAM % Used"}
ramProcessMetricTable=${ramProcessMetricTable//"Maximum RAM % Used"/"<i class='fa-solid fa-arrows-up-to-line'></i> Maximum RAM % Used"}
# Apply thresholds for RAM metrics to highlight warnings and danger levels
ramProcessMetricTable=$(
echo "$ramProcessMetricTable" | awk '
BEGIN {
dangerThreshold = 30.00
warningThreshold = 10.00
}
{
if ($0 ~ /<tr><td>/) {
split($0, tableData, "<td>")
avg = tableData[4]
min = tableData[5]
max = tableData[6]
gsub(/[^0-9.]/, "", avg)
gsub(/[^0-9.]/, "", min)
gsub(/[^0-9.]/, "", max)
avg += 0
min += 0
max += 0
# Highlight rows based on thresholds
if (avg > dangerThreshold || min > dangerThreshold || max > dangerThreshold) {
sub(/<tr>/, "<tr class='\''danger'\''>");
} else if (avg > warningThreshold || min > warningThreshold || max > warningThreshold) {
sub(/<tr>/, "<tr class='\''warning'\''>");
}
}
print $0
}
'
)
echo "Converting network usage data into HTML."
# Apply formatting to the network usage table
networkInterfaceUsage=$(echo "$netData" | convertToHTMLTable -d ";[|];")
networkInterfaceUsage=${networkInterfaceUsage//"<th>"/"<th><b>"}
networkInterfaceUsage="${networkInterfaceUsage//<\/th>/</b></th>}"
networkInterfaceUsage="${networkInterfaceUsage//<table>/<table><caption style='border-top: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #CAD0D6'><b>Network Usage</b></caption>}"
networkInterfaceUsage=${networkInterfaceUsage//"Average Sent & Received"/"<i class='fa-solid fa-arrow-down-up-across-line'></i> Average Sent & Received"}
networkInterfaceUsage=${networkInterfaceUsage//"Minimum Sent & Received"/"<i class='fa-solid fa-arrows-down-to-line'></i> Minimum Sent & Received"}
networkInterfaceUsage=${networkInterfaceUsage//"Maximum Sent & Received"/"<i class='fa-solid fa-arrows-up-to-line'></i> Maximum Sent & Received"}
networkInterfaceUsage="${networkInterfaceUsage//<th><b>Type<\/b><\/th>/<th><b><i class='fa-solid fa-network-wired'></i> Type</b></th>}"
networkInterfaceUsage="${networkInterfaceUsage//<td>Wired<\/td>/<td><i class='fa-solid fa-ethernet'></i> Wired</td>}"
networkInterfaceUsage="${networkInterfaceUsage//<td>Wi-Fi<\/td>/<td><i class='fa-solid fa-wifi'></i> Wi-Fi</td>}"
networkInterfaceUsage="${networkInterfaceUsage//<td>Other<\/td>/<td><i class='fa-solid fa-circle-question'></i> Other</td>}"
# Apply threshold-based row highlighting for network usage data
networkInterfaceUsage=$(
echo "$networkInterfaceUsage" | awk '
BEGIN {
dangerThreshold = 100.00
warningThreshold = 10.00
}
{
if ($0 ~ /<tr><td>/) {
split($0, tableData, "<td>")
avg = tableData[5]
min = tableData[6]
max = tableData[7]
networkType = tableData[4]
gsub(/ /, "", networkType)
gsub(/<i.*><\/i>/, "", networkType)
gsub(/<\/td>/, "", networkType)
gsub(/[^0-9.]/, "", avg)
gsub(/[^0-9.]/, "", min)
gsub(/[^0-9.]/, "", max)
avg += 0
min += 0
max += 0
# Highlight rows based on thresholds
if (avg > dangerThreshold || min > dangerThreshold || max > dangerThreshold) {
sub(/<tr>/, "<tr class='\''danger'\''>");
} else if (avg > warningThreshold || min > warningThreshold || max > warningThreshold || networkType != "Wired" ) {
sub(/<tr>/, "<tr class='\''warning'\''>");
}
}
print $0
}
'
)
echo "Converting disk usage data into HTML."
diskTable=$(echo "$diskData" | convertToHTMLTable -d ";[|];")
# Apply formatting to the disk usage table
diskTable=${diskTable//"<th>"/"<th><b>"}
diskTable="${diskTable//<\/th>/</b></th>}"
diskTable="${diskTable//<table>/<table><caption style='border-top: 1px; border-left: 1px; border-right: 1px; border-style: solid; border-color: #CAD0D6'><b>Disk Usage</b></caption>}"
# Apply threshold-based row highlighting for disk usage data
diskTable=$(
echo "$diskTable" | awk '
BEGIN {
dangerThreshold = 90.00
warningThreshold = 75.00
}
{
if ($0 ~ /<tr><td>/) {
split($0, tableData, "<td>")
consumedSpace = tableData[5]
mediaType = tableData[10]
gsub(/^.*\(/, "", consumedSpace)
gsub(/\).*$/, "", consumedSpace)
gsub(/%/, "", consumedSpace)
consumedSpace += 0.00
gsub(/<\/td>.*/, "", mediaType)
# Highlight rows based on thresholds and media type
if (consumedSpace > dangerThreshold || (mediaType != "SSD" && mediaType != "Unspecified" )) {
sub(/<tr>/, "<tr class='\''danger'\''>");
} else if (consumedSpace > warningThreshold) {
sub(/<tr>/, "<tr class='\''warning'\''>");
}
}
print $0
}
'
)
# Record the completion date and time
reportCompleteDate=$(date "+%x %r")
# Assemble the HTML card for the report
echo "Assembling the html data into the html card"
htmlCard="<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-gauge-high'></i> System Performance Metrics</div>
</div>
<div class='card-body' style='white-space: nowrap'>
<div class='container' style='padding-left: 0px'>
<div class='row'>
<div class='col-sm'>
<p class='card-text'><b>Start Date and Time</b><br>$reportStartDate</p>
</div>
<div class='col-sm'>
<p class='card-text'><b>Completed Date and Time</b><br>$reportCompleteDate</p>
</div>
</div>
</div>
<p id='lastStartup' class='card-text'><b>Last Startup Time</b><br>$lastStartupDate</p>
<p><b>$CPU</b></p>
<div class='container'>
<div class='row' style='padding-left: 0px'>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='cpuOverallAvg' style='color: #008001;'>$CPUAverage</div>
<div class='stat-desc'><i class='fa-solid fa-arrow-down-up-across-line'></i> Average CPU % Used</div>
</div>
</div>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='cpuOverallMin' style='color: #008001;'>$CPUMinimum</div>
<div class='stat-desc'><i class='fa-solid fa-arrows-down-to-line'></i> Minimum CPU % Used</div>
</div>
</div>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='cpuOverallMax' style='color: #008001;'>$CPUMaximum</div>
<div class='stat-desc'><i class='fa-solid fa-arrows-up-to-line'></i> Maximum CPU % Used</div>
</div>
</div>
</div>
</div>
<p><b>Total Memory: $TotalMemory</b></p>
<div class='container'>
<div class='row' style='padding-left: 0px'>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='ramOverallAvg' style='color: #008001;'>$MEMAverage</div>
<div class='stat-desc'><i class='fa-solid fa-arrow-down-up-across-line'></i> Average RAM % Used</div>
</div>
</div>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='ramOverallMin' style='color: #008001;'>$MEMMinimum</div>
<div class='stat-desc'><i class='fa-solid fa-arrows-down-to-line'></i> Minimum RAM % Used</div>
</div>
</div>
<div class='col-md'>
<div class='stat-card' style='display: flex;'>
<div class='stat-value' id='ramOverallMax' style='color: #008001;'>$MEMMaximum</div>
<div class='stat-desc'><i class='fa-solid fa-arrows-up-to-line'></i> Maximum RAM % Used</div>
</div>
</div>
</div>
</div>
$cpuProcessMetricTable
<br>
$ramProcessMetricTable
<br>
$networkInterfaceUsage
<br>
$diskTable
<br>
</div>
</div>"
if [[ "$_arg_daysSinceLastReboot" -ge 0 ]]; then
echo "Highlighting last startup date."
fi
# Check if the last startup exceeded the specified limit and update the HTML card accordingly
escapedLastStartupDate="${lastStartupDate//\//\\/}"
if [[ "$exceededLastStartupLimit" == "true" ]]; then
searchString="id='lastStartup' class='card-text'><b>Last Startup Time<\/b><br>$escapedLastStartupDate"
replacementString="id='lastStartup' class='card-text'><b>Last Startup Time</b><br>$lastStartupDate <i class='fa-solid fa-circle-exclamation' style='color: #D53948;'></i>"
htmlCard="${htmlCard//$searchString/$replacementString}"
elif [[ "$_arg_daysSinceLastReboot" -ge 0 ]]; then
searchString="id='lastStartup' class='card-text'><b>Last Startup Time<\/b><br>$escapedLastStartupDate"
replacementString="id='lastStartup' class='card-text'><b>Last Startup Time</b><br>$lastStartupDate <i class='fa-solid fa-circle-check' style='color: #008001;'></i>"
htmlCard="${htmlCard//$searchString/$replacementString}"
fi
# Highlight overall CPU usage metrics based on thresholds
echo "Highlighting overall CPU usage metrics."
CPUNumericAverage=$(echo "$CPUAverage" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
CPUNumericMaximum=$(echo "$CPUMaximum" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
CPUNumericMinimum=$(echo "$CPUMinimum" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
# Update HTML card with warning or danger thresholds for CPU usage
if [[ "$CPUNumericAverage" -ge 60 && "$CPUNumericAverage" -lt 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallAvg' style='color: #008001;'"/"id='cpuOverallAvg' style='color: #FAC905;'"}
fi
if [[ "$CPUNumericMaximum" -ge 60 && "$CPUNumericMaximum" -lt 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallMax' style='color: #008001;'"/"id='cpuOverallMax' style='color: #FAC905;'"}
fi
if [[ "$CPUNumericMinimum" -ge 60 && "$CPUNumericMinimum" -lt 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallMin' style='color: #008001;'"/"id='cpuOverallMin' style='color: #FAC905;'"}
fi
if [[ "$CPUNumericAverage" -ge 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallAvg' style='color: #008001;'"/"id='cpuOverallAvg' style='color: #D53948;'"}
fi
if [[ "$CPUNumericMaximum" -ge 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallMax' style='color: #008001;'"/"id='cpuOverallMax' style='color: #D53948;'"}
fi
if [[ "$CPUNumericMinimum" -ge 90 ]]; then
htmlCard=${htmlCard//"id='cpuOverallMin' style='color: #008001;'"/"id='cpuOverallMin' style='color: #D53948;'"}
fi
# Highlight overall RAM usage metrics based on thresholds
echo "Highlighting overall RAM usage metrics."
MEMNumericAverage=$(echo "$MEMAverage" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
MEMNumericMaximum=$(echo "$MEMMaximum" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
MEMNumericMinimum=$(echo "$MEMMinimum" | awk '{ gsub(/[^0-9.]/, ""); printf "%.0f", $1 }')
# Update HTML card with warning or danger thresholds for RAM usage
if [[ "$MEMNumericAverage" -ge 60 && "$MEMNumericAverage" -lt 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallAvg' style='color: #008001;'"/"id='ramOverallAvg' style='color: #FAC905;'"}
fi
if [[ "$MEMNumericMaximum" -ge 60 && "$MEMNumericMaximum" -lt 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallMax' style='color: #008001;'"/"id='ramOverallMax' style='color: #FAC905;'"}
fi
if [[ "$MEMNumericMinimum" -ge 60 && "$MEMNumericMinimum" -lt 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallMin' style='color: #008001;'"/"id='ramOverallMin' style='color: #FAC905;'"}
fi
if [[ "$MEMNumericAverage" -ge 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallAvg' style='color: #008001;'"/"id='ramOverallAvg' style='color: #D53948;'"}
fi
if [[ "$MEMNumericMaximum" -ge 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallMax' style='color: #008001;'"/"id='ramOverallMax' style='color: #D53948;'"}
fi
if [[ "$MEMNumericMinimum" -ge 90 ]]; then
htmlCard=${htmlCard//"id='ramOverallMin' style='color: #008001;'"/"id='ramOverallMin' style='color: #D53948;'"}
fi
# Store the assembled HTML card as the WYSIWYG value
wysiwygValue="$htmlCard"
# Check if event data needs to be included in the WYSIWYG field
if [[ "$_arg_numberOfEvents" -gt 0 ]]; then
echo "Converting system log data into an HTML format."
fi
# Convert system log error events to HTML format or handle missing events
if [[ "$_arg_numberOfEvents" -gt 0 && -n "$errorEvents" ]]; then
eventLogTableMetrics=$(echo "$errorEvents" | convertToHTMLTable -d ";[|];")
eventLogTableMetrics=$(sed -e 's/<th>/<th><b>/g' \
-e 's/<\/th>/<\/b><\/th>/g' \
-e 's/<th><b>Service/<th style="width: 250px"><b>Service/g' \
-e 's/<th><b>PID/<th style="width: 75px"><b>PID/g' \
-e 's/<th><b>Time Created/<th style="width: 200px"><b>Time Created/g' \
<<< "$eventLogTableMetrics")
elif [[ "$_arg_numberOfEvents" -gt 0 ]]; then
eventLogTableMetrics="<p style='margin-top: 0px'>No error events were found in the system log since the last boot.</p>"
fi
# Append event log data to the WYSIWYG value
if [[ "$_arg_numberOfEvents" -gt 0 ]]; then
eventLogCard="<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-book'></i> Recent Error Events</div>
</div>
<div class='card-body' style='white-space: nowrap'>
$eventLogTableMetrics
</div>
</div>"
wysiwygValue+="$eventLogCard"
# Check and handle the 40,000-character limit for the WYSIWYG field
characterCount=${#wysiwygValue}
if [[ "$characterCount" -gt 40000 ]]; then
echo "The current character count is '$characterCount'."
echo "[Warning] The 40,000-character limit has been reached! Trimming output to fit within the allowed limit..."
fi
while [[ "$characterCount" -gt 40000 ]]; do
totalLines=$(printf "%s" "$eventLogTableMetrics" | wc -l | xargs)
exceededAmount=$((characterCount - 40000))
if [[ "$exceededAmount" -lt 500 ]]; then
batchSize=3
else
batchSize=$(((exceededAmount + 499) / 500))
[[ $batchSize -lt 3 ]] && batchSize=3
fi
# Keep only the remaining lines within the limit
linesToKeep=$((totalLines - batchSize))
eventLogTableMetrics=$(echo "$eventLogTableMetrics" | head -n "$linesToKeep")
eventLogTableMetrics+=$'\n'"</tbody>"
eventLogTableMetrics+=$'\n'"</table>"
wysiwygValue="$htmlCard"
eventLogCard="<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-book'></i> Recent Error Events</div>
</div>
<div class='card-body' style='white-space: nowrap'>
$eventLogTableMetrics
</div>
</div>"
wysiwygValue+="<h1>This info has been truncated to accommodate the 40,000 character limit.</h1>"
wysiwygValue+="$eventLogCard"
characterCount=${#wysiwygValue}
done
fi
# Attempt to set the custom field using ninjarmm-cli
echo ""
echo "Attempting to set the custom field '$_arg_wysiwygCustomField'."
# Try to set the multiline custom field using ninjarmm-cli and capture the output
if ! output=$("$ninjarmmcliPath" set "$_arg_wysiwygCustomField" "$wysiwygValue" 2>&1); then
echo "[Error] $output" >&2
exitCode=1
else
echo "Successfully set the custom field '$_arg_wysiwygCustomField'."
fi
fi
# Display the last number of events in the system log
if [[ -n "$_arg_numberOfEvents" ]]; then
echo ""
echo "### Last $_arg_numberOfEvents warnings and errors in the system log. ###"
if [[ -n "$errorEvents" ]]; then
echo "$errorEvents" | sed '1d' | awk -F ";[|];" '{
print "Service: " $1
print "PID: " $2
print "Time Created: " $3
print "Message: " $4
print ""
}'
else
echo "No errors or warnings were found in the system log. Ensure the system log service is enabled and configured with an adequate file size limit."
fi
fi
# Attempt to remove the lock file
if [[ -f "$lockFile" ]]; then
if ! rm "$lockFile"; then
echo "[Error] Failed to remove the lock file at '$lockFile'. This file prevents multiple instances of the performance script from running simultaneously." >&2
exitCode=1
fi
fi
exit "$exitCode"
Detailed Breakdown
This shell script is a full-scale diagnostic utility designed for macOS Ventura and later. It performs the following high-level operations:
- Input Validation and Argument Parsing
Accepts arguments like –daysSinceLastReboot, –durationToPerformTests, –numberOfEvents, and –wysiwygCustomFieldName. These control the scope and depth of the diagnostic test. - Startup Sanity Checks
Ensures root permissions, prevents concurrent script execution (using a lockfile), and validates NinjaOne’s ninjarmm-cli tool path if the WYSIWYG field update is needed. - Data Collection Phase
- CPU Metrics: Utilizes top to collect per-process and system-wide CPU usage.
- Memory Metrics: Uses memory_pressure and top to determine RAM usage per process.
- Disk Metrics: Gathers volume-level statistics with diskutil apfs list -plist.
- Network Metrics: Leverages nettop and ifconfig to analyze traffic per adapter.
- Event Logs: Extracts recent error messages from the macOS unified logging system using log show.
- Data Aggregation & Formatting
After collecting raw logs, the script processes the data into structured tables. For example:- Top 5 CPU and RAM consuming processes (avg/min/max)
- Disk utilization across volumes
- Network throughput per interface
- A system information summary (CPU model, memory installed, etc.)
- Optional WYSIWYG Custom Field Update
If a WYSIWYG field is specified, the script builds a beautifully formatted HTML card using Font Awesome icons and threshold-based color indicators to highlight abnormal metrics. - Post-cleanup & Final Output
All temporary logs are purged, and performance results are printed to the terminal. If configured, the results are pushed to NinjaOne.
🖼️ Suggested Visual Aid: A flowchart showing the script stages—validation → data collection → formatting → optional upload.
Potential Use Cases
Imagine an MSP managing a fleet of executive MacBooks. A user reports that “my computer feels sluggish,” but intermittent issues are hard to catch in real time. With this script:
- The MSP runs it remotely via NinjaOne.
- They receive a WYSIWYG report showing 99% RAM usage due to a misbehaving app.
- A technician quickly remediates the issue and documents the resolution—all without needing a screen share or log file transfer.
This enhances SLA compliance, reduces MTTR (mean time to resolution), and improves user satisfaction.
Comparisons
Traditional approaches involve:
- Manually launching Activity Monitor or Console.
- Using third-party apps like iStat Menus or CleanMyMac.
- Deploying enterprise monitoring agents (with licensing and performance overhead).
Compared to those, this script:
- Requires no GUI interaction.
- Runs natively on macOS.
- Integrates seamlessly with NinjaOne’s scripting engine and custom fields.
It’s lightweight, repeatable, and fully auditable.
Implications
System performance issues—especially on endpoints like macOS—often go undiagnosed until users complain. This script empowers IT teams to:
- Detect and preemptively act on high resource usage.
- Document system health over time for forensic review.
- Identify software that degrades performance (e.g., background services or browser tabs consuming excessive RAM).
From a security angle, it also flags anomalies in network traffic or unexpected service logs.
Recommendations
- Schedule Regular Runs: Deploy this script weekly or post-patch cycles.
- Use Custom Field Automation: Pair with NinjaOne workflows to escalate based on CPU or RAM thresholds.
- Tune Your Thresholds: Adjust warning/danger levels based on environment norms.
- Log Long-Term Trends: Export results to a central dashboard for trend analysis.
Final Thoughts
For IT teams managing macOS devices at scale, visibility is power. This shell script bridges the gap between system diagnostics and actionable insights by delivering performance snapshots directly into NinjaOne’s custom fields. It exemplifies how automation and integration can streamline IT workflows, improve end-user experience, and maintain system integrity.
If you’re using NinjaOne and haven’t yet explored the power of shell scripting on macOS endpoints—this script is your starting point.