User experience is a critical metric in today’s IT landscape, particularly for IT administrators and Managed Service Providers (MSPs) managing end-user environments. With increasing emphasis on Digital Experience Monitoring (DEM) and Device Experience (DEX), the ability to collect, store, and analyze user feedback efficiently has become a priority. Automating this process ensures consistent insights and reduces manual overhead. This article explores how to collect user feedback with PowerShell and store it in NinjaOne custom fields — transforming raw feedback into structured, actionable intelligence.
Background
As organizations move toward proactive IT support, the DEX Survey experience has become a valuable tool to measure how users perceive device performance, responsiveness, and usability. The script discussed here is the second in a three-part workflow. Script 1 prompts users for feedback and logs their responses locally. Script 2 — the focus of this article — reads those log files, processes the latest feedback, and saves it into NinjaOne’s custom fields: one for recent feedback and one for feedback history (optional). For IT professionals using NinjaOne, this seamless integration ensures user sentiment data is readily accessible within the RMM platform for reporting or remediation.
The Script
#Requires -Version 5.1
<#
.SYNOPSIS
Script 2 of 3 in the DEX Survey experience. Saves the latest feedback from DEX Survey - Script 1 to the multiline custom field 'Latest User Feedback' and optionally stores feedback history in the WYSIWYG field 'User Feedback History'.
.DESCRIPTION
Script 2 of 3 in the DEX Survey experience. Saves the latest feedback from DEX Survey - Script 1 to the multiline custom field 'Latest User Feedback' and optionally stores feedback history in the WYSIWYG field 'User Feedback History'.
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
-MaxCommentsToKeep "30" -KeepFeedbackHistory
Gathering all user profiles.
Gathering each user's feedback log location.
Checking 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Checking 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Checking 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Compiling the most recent feedback.
All the most recent feedback has already been collected.
Attempting to save the feedback to the custom field 'userFeedbackHistory'.
Successfully saved the feedback history to the custom field.
Updating the feedback log(s) to indicate that the feedback has been collected.
Updated the log file at 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Updated the log file at 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Updated the log file at 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Cleaning up the log files so only the previous '30' are kept.
The log file at 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
The log file at 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
The log file at 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
.PARAMETER KeepFeedbackHistory
This stores the user feedback history in a custom field titled 'User Feedback History'.
.PARAMETER MaxCommentsToKeep
This controls the number of feedback comments to retain in the log file(s).
.PARAMETER MultilineCustomFieldName
The name of a multiline custom field to store the latest user feedback.
.PARAMETER WYSIWYGCustomFieldName
The name of a WYSIWYG custom field to store the total user feedback history.
.NOTES
Minimum OS Architecture Supported: Windows 10, Windows Server 2016
Release Notes: Initial Release
#>
[CmdletBinding()]
param (
[Parameter()]
[Switch]$KeepFeedbackHistory = [System.Convert]::ToBoolean($env:KeepFeedbackHistory),
[Parameter()]
[String]$MaxCommentsToKeep = "30",
[Parameter()]
[String]$MultilineCustomFieldName = "latestUserFeedback",
[Parameter()]
[String]$WYSIWYGCustomFieldName = "userFeedbackHistory"
)
begin {
# If the script form variables are used, replace the command-line parameters with their values.
if ($env:maxCommentsToKeep) { $MaxCommentsToKeep = $env:maxCommentsToKeep }
# Check if the operating system build version is less than 10240 (Windows 10 or Windows Server 2016 minimum requirement)
if ([System.Environment]::OSVersion.Version.Build -lt 10240) {
Write-Host -Object "`n[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' detected."
Write-Host -Object "[Warning] The minimum OS version supported by this script is Windows 10 (10240) or Windows Server 2016 (14393).`n"
}
# Validate that the maximum number of comments to keep is not null or empty
if ([String]::IsNullOrWhiteSpace($MaxCommentsToKeep)) {
Write-Host -Object "[Error] You must specify a maximum number of comments to keep."
exit 1
}
# Ensure the value for 'MaxCommentsToKeep' contains only numeric characters
if ($MaxCommentsToKeep -match "[^0-9]") {
Write-Host -Object "[Error] The maximum number of comments you specified '$MaxCommentsToKeep' is invalid. It contains invalid characters."
Write-Host -Object "[Error] Please specify a positive whole number that is greater than 0 and less than $([int]::MaxValue)."
exit 1
}
# Attempt to convert the 'MaxCommentsToKeep' value to a long integer
try {
[long]$CommentsToKeep = $MaxCommentsToKeep
} catch {
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Unable to convert '$MaxCommentsToKeep' into an integer."
exit 1
}
# Validate that the converted value is within the acceptable range
if ($CommentsToKeep -le 0 -or $CommentsToKeep -ge [int]::MaxValue) {
Write-Host -Object "[Error] The maximum number of comments you specified '$CommentsToKeep' is invalid."
Write-Host -Object "[Error] Please specify a positive whole number that is greater than 0 and less than $([int]::MaxValue)."
exit 1
}
function ConvertFrom-UrlSafeBase64 {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[String]$UrlSafeBase64
)
# Normalize base64 (replace URL-safe chars)
$Base64 = $UrlSafeBase64.Replace('-', '+').Replace('_', '/')
# Add padding if needed
switch ($Base64.Length % 4) {
2 { $Base64 = "$Base64==" }
3 { $Base64 = "$Base64=" }
1 { throw "Invalid Base64 string" }
}
# Convert from base64 to text
$InputInBytes = [System.Convert]::FromBase64String($Base64)
[System.Text.Encoding]::UTF8.GetString($InputInBytes)
}
function Get-UserHive {
[CmdletBinding()]
param (
[Parameter()]
[ValidateSet('AzureAD', 'DomainAndLocal', 'All')]
[String]$Type = "All",
[Parameter()]
[String[]]$ExcludedUsers,
[Parameter()]
[switch]$IncludeDefault
)
# Define the SID patterns to match based on the selected user type
$Patterns = switch ($Type) {
"AzureAD" { "S-1-12-1-(\d+-?){4}$" }
"DomainAndLocal" { "S-1-5-21-(\d+-?){4}$" }
"All" { "S-1-12-1-(\d+-?){4}$" ; "S-1-5-21-(\d+-?){4}$" }
}
# Retrieve user profile information based on the defined patterns
$UserProfiles = Foreach ($Pattern in $Patterns) {
Get-ItemProperty "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*" |
Where-Object { $_.PSChildName -match $Pattern } |
Select-Object @{Name = "SID"; Expression = { $_.PSChildName } },
@{Name = "Username"; Expression = { "$($_.ProfileImagePath | Split-Path -Leaf)" } },
@{Name = "Domain"; Expression = { if ($_.PSChildName -match "S-1-12-1-(\d+-?){4}$") { "AzureAD" }else { $Null } } },
@{Name = "UserHive"; Expression = { "$($_.ProfileImagePath)\NTuser.dat" } },
@{Name = "Path"; Expression = { $_.ProfileImagePath } }
}
# If the IncludeDefault switch is set, add the Default profile to the results
switch ($IncludeDefault) {
$True {
$DefaultProfile = "" | Select-Object Username, SID, UserHive, Path
$DefaultProfile.Username = "Default"
$DefaultProfile.Domain = $env:COMPUTERNAME
$DefaultProfile.SID = "DefaultProfile"
$DefaultProfile.Userhive = "$env:SystemDrive\Users\Default\NTUSER.DAT"
$DefaultProfile.Path = "C:\Users\Default"
# Exclude users specified in the ExcludedUsers list
$DefaultProfile | Where-Object { $ExcludedUsers -notcontains $_.Username }
}
}
if ($PSVersionTable.PSVersion.Major -lt 3) {
$AllAccounts = Get-WmiObject -Class "win32_UserAccount"
} else {
$AllAccounts = Get-CimInstance -ClassName "win32_UserAccount"
}
$CompleteUserProfiles = $UserProfiles | ForEach-Object {
$SID = $_.SID
$Win32Object = $AllAccounts | Where-Object { $_.SID -like $SID }
if ($Win32Object) {
$Win32Object | Add-Member -NotePropertyName UserHive -NotePropertyValue $_.UserHive
$Win32Object | Add-Member -NotePropertyName Path -NotePropertyValue $_.Path
$Win32Object
} else {
[PSCustomObject]@{
Name = $_.Username
Domain = $_.Domain
SID = $_.SID
UserHive = $_.UserHive
Path = $_.Path
}
}
}
# Return the list of user profiles, excluding any specified in the ExcludedUsers list
$CompleteUserProfiles | Where-Object { $ExcludedUsers -notcontains $_.Name }
}
function Test-IsElevated {
[CmdletBinding()]
param ()
# Get the current Windows identity of the user running the script
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
# Create a WindowsPrincipal object based on the current identity
$p = New-Object System.Security.Principal.WindowsPrincipal($id)
# Check if the current user is in the Administrator role
# The function returns $True if the user has administrative privileges, $False otherwise
# 544 is the value for the Built In Administrators role
# Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
$p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
}
if (!$ExitCode) {
$ExitCode = 0
}
}
process {
# Attempt to determine if the current session is running with Administrator privileges.
try {
$IsElevated = Test-IsElevated -ErrorAction Stop
} catch {
# Log an error if unable to determine elevation status
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running with Administrator privileges."
exit 1
}
# Exit if the script is not running with Administrator privileges
if (!$IsElevated) {
Write-Host -Object "[Error] Access Denied: The user '$env:Username' does not have administrator privileges, or the script is not running with elevated permissions."
exit 1
}
try {
# Retrieve all user profiles on the system
Write-Host -Object "Gathering all user profiles."
$UserProfiles = Get-UserHive -Type "All" -ErrorAction Stop
$ProfileWasLoaded = New-Object System.Collections.Generic.List[object]
} catch {
# Log an error if unable to gather user profiles
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to gather all the user profiles."
exit 1
}
# Loop through each user profile to load their registry hive if not already loaded
ForEach ($UserProfile in $UserProfiles) {
If (!(Test-Path -Path Registry::HKEY_USERS\$($UserProfile.SID) -ErrorAction SilentlyContinue)) {
Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe LOAD HKU\$($UserProfile.SID) `"$($UserProfile.UserHive)`"" -Wait -WindowStyle Hidden
$ProfileWasLoaded.Add($UserProfile)
}
}
# Locate feedback log files for each user profile
Write-Host -Object "Gathering each user's feedback log location."
$FeedbackLogLocations = New-Object System.Collections.Generic.List[object]
$UserProfiles | ForEach-Object {
# Check for feedback log file in various locations
$AppDataLocation = Get-ItemProperty -Path "Registry::HKEY_USERS\$($_.SID)\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty "Local AppData" -ErrorAction SilentlyContinue
if ($AppDataLocation -and (Test-Path -Path "$AppDataLocation\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue)) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "$AppDataLocation\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
if (Test-Path -Path "$($_.Path)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "$($_.Path)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
if (Test-Path -Path "C:\Users\$($_.Name)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "C:\Users\$($_.Name)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
# Log a message if no feedback log file is found for the user
Write-Host -Object "No feedback log file found for $($_.Name) in the domain $($_.Domain)."
}
# Unload user registry hives if they were loaded during this script execution
if ($ProfileWasLoaded.Count -gt 0) {
ForEach ($UserProfile in $ProfileWasLoaded) {
[gc]::Collect()
Start-Sleep 1
Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe UNLOAD HKU\$($UserProfile.SID)" -Wait -WindowStyle Hidden | Out-Null
}
}
# Log a warning if no feedback log files were found
if ($FeedbackLogLocations.Count -lt 1) {
Write-Host -Object "[Warning] No feedback was found."
}
Write-Host -Object ""
# Initialize a list to store all feedback entries
$AllFeedback = New-Object System.Collections.Generic.List[object]
$FeedbackLogLocations | ForEach-Object {
$LogFileLocation = $_.LogFileLocation
# Check the feedback log file for user feedback
Write-Host -Object "Checking '$LogFileLocation' for user feedback."
try {
# Read the first line of the log file to ensure it exists
Get-Content -Path $LogFileLocation -TotalCount 1 -ErrorAction Stop | Out-Null
} catch {
# Log an error if unable to read the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the feedback log file contents for the user '$($_.Name)'."
$ExitCode = 1
return
}
# Parse the feedback log file contents
$i = 0
Get-Content -Path $LogFileLocation -Tail 200000 | ForEach-Object {
$i++
$DataPoints = $_ -split '\|', 6
# Warn if a line has fewer than 6 data points
if ($DataPoints.Count -lt 6) {
Write-Host -Object "[Warning] Only $($DataPoints.Count) data points have been found for line '$i'."
return
}
# Parse the timestamp from the log entry
try {
$CurrentTimeStamp = [datetime]::ParseExact($DataPoints[0], 'yyyy-MM-dd HH:mm:ssK', $null)
} catch {
Write-Host -Object "[Warning] $($_.Exception.Message)"
Write-Host -Object "[Warning] Failed to parse the date '$($DataPoints[0])' from the feedback log."
}
# Decode the feedback message from Base64
try {
$UserMessage = ConvertFrom-UrlSafeBase64 -UrlSafeBase64 $DataPoints[5] -ErrorAction Stop
} catch {
Write-Host -Object "[Warning] $($_.Exception.Message)"
Write-Host -Object "[Warning] Failed to parse the following feedback message from the feedback log: $($DataPoints[5])"
}
# Add the parsed feedback entry to the list
$AllFeedback.Add(
[PSCustomObject]@{
TimeStamp = $CurrentTimeStamp
Username = $DataPoints[1]
Domain = $DataPoints[2]
FeedbackCollected = $DataPoints[3]
SystemPerformanceHasRun = $DataPoints[4]
Message = $UserMessage
Base64 = $DataPoints[5]
LogLocation = $LogFileLocation
}
)
}
# Filter feedback entries for the current log file
$UserFeedback = $AllFeedback | Where-Object { $_.LogLocation -eq $LogFileLocation }
# Log a warning if no feedback entries are found in the log file
if (!$UserFeedback) {
Write-Host -Object "[Warning] The log file does not contain any feedback."
Write-Host -Object ""
return
}
# Check if there is new feedback in the log file
if (!($UserFeedback | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "There is no new feedback in this log file."
} else {
Write-Host -Object "New feedback was found."
}
Write-Host -Object ""
}
# Check if there is no feedback collected
if ($AllFeedback.Count -eq 0) {
try {
# Retrieve the current value of the custom field for the latest feedback
Write-Host -Object "Checking the current value of the custom field '$MultilineCustomFieldName'."
$CurrentCustomFieldValue = Get-NinjaProperty -Name $MultilineCustomFieldName -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the value from the custom field $MultilineCustomFieldName."
$ExitCode = 1
}
# Update the custom field if it is not already set to "No feedback has been given."
if ($CurrentCustomFieldValue -ne "No feedback has been given.") {
try {
Write-Host -Object "Attempting to set the custom field '$MultilineCustomFieldName'."
Set-NinjaProperty -Name $MultilineCustomFieldName -Value "No feedback has been given." -ErrorAction Stop
Write-Host -Object "Successfully saved the feedback to the custom field."
} catch {
# Log an error if unable to update the custom field
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save to the custom field $MultilineCustomFieldName."
$ExitCode = 1
}
} else {
Write-Host -Object "The custom field is already set to 'No feedback has been given.'."
}
# Handle feedback history if the KeepFeedbackHistory switch is enabled
if ($KeepFeedbackHistory) {
try {
# Retrieve the current value of the custom field for feedback history
Write-Host -Object "`nChecking the current value of the custom field '$WYSIWYGCustomFieldName'."
$CurrentCustomFieldValue = Get-NinjaProperty -Name $WYSIWYGCustomFieldName -Type "WYSIWYG" -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the value from the custom field $WYSIWYGCustomFieldName."
$ExitCode = 1
}
# Trim the current value if it is not null or empty
if (![string]::IsNullOrWhiteSpace($CurrentCustomFieldValue.Text)) {
$CurrentCustomFieldValue = $CurrentCustomFieldValue.Text.Trim()
}
# Update the custom field if it is not already set to "No feedback has been given."
if ($CurrentCustomFieldValue -ne "No feedback has been given.") {
try {
Write-Host -Object "Attempting to set the custom field '$WYSIWYGCustomFieldName'."
Set-NinjaProperty -Name $WYSIWYGCustomFieldName -Value "No feedback has been given." -ErrorAction Stop
Write-Host -Object "Successfully saved the feedback history to the custom field."
} catch {
# Log an error if unable to update the custom field
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save to the custom field $WYSIWYGCustomFieldName."
$ExitCode = 1
}
} else {
Write-Host -Object "The custom field is already set to 'No feedback has been given.'."
}
}
# Exit the script with the current exit code
exit $ExitCode
}
# Compile the most recent feedback
Write-Host -Object "Compiling the most recent feedback."
if ($AllFeedback.Count -gt 1) {
# Sort feedback by timestamp in descending order
$AllFeedback = [System.Collections.Generic.List[object]]::new(($AllFeedback | Sort-Object -Property "Timestamp" -Descending))
}
# Initialize lists for most recent feedback and collected feedback
$MostRecentFeedback = New-Object System.Collections.Generic.List[object]
$FeedbackCollected = New-Object System.Collections.Generic.List[object]
# Group feedback by log location and retrieve the most recent entry for each user
$FeedbackPerUser = $AllFeedback | Group-Object -Property "LogLocation"
$FeedbackPerUser | ForEach-Object {
$MostRecentFeedback.Add(
(
$_.Group | Sort-Object -Property TimeStamp | Select-Object -Last 1
)
)
}
# Check if all the most recent feedback has already been collected
if (!($MostRecentFeedback | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "All the most recent feedback has already been collected.`n"
}
# Process new feedback if available
if ($MostRecentFeedback | Where-Object { $_.FeedbackCollected -eq $False }) {
$LatestUserFeedbackValue = New-Object System.Collections.Generic.List[String]
if ($MostRecentFeedback.Count -gt 1) {
# Sort most recent feedback by timestamp in descending order
$MostRecentFeedback = [System.Collections.Generic.List[object]]::new(($MostRecentFeedback | Sort-Object -Property "TimeStamp" -Descending))
}
# Format the feedback entries for display
$MostRecentFeedback | ForEach-Object {
$DateString = "$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
$LatestUserFeedbackValue.Add("$DateString | $($_.Username) | $($_.Domain)")
$LatestUserFeedbackValue.Add([String]$_.Message)
$LatestUserFeedbackValue.Add("")
}
try {
# Check if the character limit for the custom field is exceeded
$Characters = ($LatestUserFeedbackValue | Out-String) | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters
if ($Characters -ge 9500) {
Write-Host -Object "[Warning] The character limit of 10,000 has been reached! Trimming the output until the character limit is satisfied."
$TrimStart = Get-Date
do {
# Notify the user that data is being truncated
$LatestUserFeedbackValue = New-Object System.Collections.Generic.List[String]
$LatestUserFeedbackValue.Add("This info has been truncated to accommodate the 10,000 character limit.")
$LatestUserFeedbackValue.Add("")
# Calculate the batch size for trimming
$ExceededAmount = $Characters - 9500
if ($ExceededAmount -le 0) {
$BatchSize = 1
} else {
$BatchSize = [math]::Ceiling($ExceededAmount / 500)
}
# Remove excess feedback entries
for ($i = 0 ; $i -lt $BatchSize ; $i++) {
$MostRecentFeedback.RemoveAt($MostRecentFeedback.Count - 1)
}
# Reformat the remaining feedback entries
$MostRecentFeedback | ForEach-Object {
$DateString = "$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
$LatestUserFeedbackValue.Add("$DateString | $($_.Username) | $($_.Domain)")
$LatestUserFeedbackValue.Add([String]$_.Message)
$LatestUserFeedbackValue.Add("")
}
# Recalculate character count and continue trimming if necessary
$Characters = ($LatestUserFeedbackValue | Out-String) | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters
$ElapsedTime = (Get-Date) - $TrimStart
if ($ElapsedTime.TotalMinutes -ge 5) {
Write-Host -Object "The current character count is '$Characters'."
throw "5 minute timeout reached. Unable to trim the output to comply with the character limit."
}
} while ($Characters -ge 9500)
}
# Save the most recent feedback to the custom field
Write-Host -Object "Attempting to set the custom field '$MultilineCustomFieldName'."
$LatestUserFeedbackValue | Set-NinjaProperty -Name $MultilineCustomFieldName -ErrorAction Stop
$MostRecentFeedback | ForEach-Object { $FeedbackCollected.Add($_) }
Write-Host -Object "Successfully saved the feedback to the custom field.`n"
} catch {
# Log an error if unable to save the feedback
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save the most recent feedback to the custom field '$MultilineCustomFieldName'."
$ExitCode = 1
}
}
# Check if feedback history should be kept and if all feedback entries have already been collected
if ($KeepFeedbackHistory -and !($AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "The past '$CommentsToKeep' feedback entries have already been collected.`nAttempting to verify if any update is necessary."
try {
# Retrieve the current value of the custom field for feedback history
Write-Host -Object "Checking the current value of the custom field '$WYSIWYGCustomFieldName'."
$WysiwygValue = Get-NinjaProperty -Name $WYSIWYGCustomFieldName -ErrorAction Stop
# Extract the HTML content from the custom field value if it exists
if ($WysiwygValue) {
$WysiwygHTML = $WysiwygValue | ConvertFrom-Json -ErrorAction Stop | Select-Object -ExpandProperty HTML -ErrorAction SilentlyContinue
}
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the current value of the custom field '$WYSIWYGCustomFieldName'."
$ExitCode = 1
}
# Calculate the expected number of feedback entries based on the specified limit
$ExpectedNumberOfEntries = $AllFeedback | Select-Object -First $CommentsToKeep | Measure-Object -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Count -ErrorAction SilentlyContinue
# Check if the current HTML content matches the expected number of entries
if ($WysiwygHTML) {
# Count the number of entries in the current HTML content
$WysiwygEntries = $WysiwygHTML -split "<tr>" | Where-Object { $_ -match "</td>" } | Measure-Object -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Count -ErrorAction SilentlyContinue
# Determine if an update is necessary based on the mismatch in entry counts
if ($WysiwygEntries -ne $ExpectedNumberOfEntries) {
Write-Host -Object "There are currently '$WysiwygEntries' entries when '$ExpectedNumberOfEntries' entries were expected. An update is necessary."
$NeedToUpdateWysiwyg = $True
}
} else {
# Log a message if no entries exist in the current HTML content
Write-Host -Object "There are currently no entries when '$ExpectedNumberOfEntries' entries were expected. An update is necessary."
$NeedToUpdateWysiwyg = $True
}
# Log a message if no update is necessary
if (!$NeedToUpdateWysiwyg) {
Write-Host -Object "The custom field '$WYSIWYGCustomFieldName' contains all '$ExpectedNumberOfEntries' expected entries. No update is necessary."
}
Write-Host -Object ""
}
# Check if feedback history should be kept and if there is new feedback to process
if ($KeepFeedbackHistory -and (($AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $_.FeedbackCollected -eq $False }) -or $NeedToUpdateWyswiyg)) {
# Initialize a list to store the feedback history in HTML format
$FeedbackHistoryValue = New-Object System.Collections.Generic.List[String]
# Begin the HTML structure
$FeedbackHistoryValue.Add("<div>")
# Convert feedback data into an HTML table
$HTMLTable = $AllFeedback | Select-Object -First $CommentsToKeep | Select-Object @{ Name = 'Feedback Date'; Expression = {
"$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
}
}, Username, Domain, Message | ConvertTo-Html -Fragment
# Apply styling to the HTML table headers
$HTMLTable = $HTMLTable -replace '<th>', "<th><b>" -replace '<\/th>', "</b></th>"
$HTMLTable = $HTMLTable -replace '<th><b>Feedback Date', "<th style='width: 15em'><b>Feedback Date"
$HTMLTable = $HTMLTable -replace '<th><b>Username', "<th style='width: 15em'><b>Username"
$HTMLTable = $HTMLTable -replace '<th><b>Domain', "<th style='width: 15em'><b>Domain"
# Wrap the table in a styled HTML card
$HTMLCard = "<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-comment'></i> User Feedback History</div>
</div>
<div class='card-body' style='white-space: nowrap'>
<div>
<br>
$HTMLTable
</div>
</div>
</div>"
# Add the card to the feedback history
$FeedbackHistoryValue.Add($HTMLCard)
$FeedbackHistoryValue.Add("</div>")
try {
# Check if the character limit for the custom field is exceeded
$Characters = ($FeedbackHistoryValue | Out-String).Length
if ($Characters -ge 190000) {
Write-Host -Object "The current character count is '$Characters'."
Write-Host -Object "[Warning] The character limit of 200,000 has been reached! Trimming the output until the character limit is satisfied."
$TrimStart = Get-Date
do {
# Notify the user that data is being truncated
$FeedbackHistoryValue = New-Object System.Collections.Generic.List[String]
$FeedbackHistoryValue.Add("<div>")
$FeedbackHistoryValue.Add("<h1>This information has been truncated to fit within the character limit of 200,000.</h1>")
# Calculate the batch size for trimming
$ExceededAmount = $Characters - 190000
if ($ExceededAmount -le 0) {
$BatchSize = 1
} else {
$BatchSize = [math]::Ceiling($ExceededAmount / 500)
}
# Remove excess feedback entries
for ($i = 0 ; $i -lt $BatchSize ; $i++) {
$AllFeedback.RemoveAt($AllFeedback.Count - 1)
}
# Rebuild the HTML table with the remaining feedback
$HTMLTable = $AllFeedback | Select-Object -First $CommentsToKeep | Select-Object @{ Name = 'Feedback Date'; Expression = {
"$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
}
}, Username, Domain, Message | ConvertTo-Html -Fragment
$HTMLTable = $HTMLTable -replace '<th>', "<th><b>" -replace '<\/th>', "</b></th>"
$HTMLTable = $HTMLTable -replace '<th><b>Feedback Date', "<th style='width: 15em'><b>Feedback Date"
$HTMLTable = $HTMLTable -replace '<th><b>Username', "<th style='width: 15em'><b>Username"
$HTMLTable = $HTMLTable -replace '<th><b>Domain', "<th style='width: 15em'><b>Domain"
# Wrap the updated table in a styled HTML card
$HTMLCard = "<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-comment'></i> User Feedback History</div>
</div>
<div class='card-body' style='white-space: nowrap'>
<div>
<br>
$HTMLTable
</div>
</div>
</div>"
# Add the updated card to the feedback history
$FeedbackHistoryValue.Add($HTMLCard)
$FeedbackHistoryValue.Add("</div>")
# Recalculate character count and continue trimming if necessary
$Characters = ($FeedbackHistoryValue | Out-String).Length
$ElapsedTime = (Get-Date) - $TrimStart
if ($ElapsedTime.TotalMinutes -ge 5) {
Write-Host -Object "The current character count is '$Characters'."
throw "5 minute timeout reached. Unable to trim the output to comply with the character limit."
}
} while ($Characters -ge 190000)
}
# Save the feedback history to the custom field
Write-Host -Object "Attempting to save the feedback to the custom field '$WYSIWYGCustomFieldName'."
$FeedbackHistoryValue | Set-NinjaProperty -Name $WYSIWYGCustomFieldName -Type "WYSIWYG" -ErrorAction Stop
$AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $FeedbackCollected -notcontains $_ } | ForEach-Object { $FeedbackCollected.Add($_) }
Write-Host -Object "Successfully saved the feedback history to the custom field.`n"
} catch {
# Log an error if unable to save the feedback history
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save the feedback history to the custom field '$WYSIWYGCustomFieldName'.`n"
$ExitCode = 1
}
}
# Exit if no feedback was collected
if ($FeedbackCollected.Count -eq 0) {
exit $ExitCode
}
# Update feedback logs to mark feedback as collected
Write-Host -Object "Updating the feedback log(s) to indicate that the feedback has been collected."
$LogsToUpdate = $FeedbackCollected | Group-Object -Property "LogLocation"
$LogsToUpdate | ForEach-Object {
try {
# Retrieve the log file content
$LogPath = $_.Name
$LogContent = Get-Content -Path $LogPath -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the log file at '$($_.Name)'."
$ExitCode = 1
return
}
try {
# Create a map of updates for the log file
$UpdateMap = @{}
$_.Group | ForEach-Object {
$Key = "$($_.TimeStamp.ToString('yyyy-MM-dd HH:mm:ssK'))|$($_.Username)|$($_.Domain)|$($_.FeedbackCollected)"
$Message = $_.Base64
$UpdateMap["$Key;$Message"] = "$($_.TimeStamp.ToString('yyyy-MM-dd HH:mm:ssK'))|$($_.Username)|$($_.Domain)|$True"
}
# Update the log file content with the collected feedback status
$LogContent = $LogContent | ForEach-Object {
$Line = $_
foreach ($UpdateKey in $UpdateMap.Keys) {
$EscapedKey = [regex]::Escape(($UpdateKey -split ';', 2)[0])
$EscapedMsg = [regex]::Escape(($UpdateKey -split ';', 2)[1])
if ($Line -match $EscapedKey -and $Line -match $EscapedMsg) {
$Line = $Line -replace $EscapedKey, $UpdateMap[$UpdateKey]
}
}
$Line
}
# Save the updated log file content
Set-Content -Path $LogPath -Value $LogContent -ErrorAction Stop
Write-Host -Object "Updated the log file at '$($_.Name)'."
} catch {
# Log an error if unable to update the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to update the log file at '$($_.Name)'."
$ExitCode = 1
}
}
# Clean up log files to retain only the specified number of comments
Write-Host -Object "`nCleaning up the log files so only the previous '$CommentsToKeep' are kept."
$FeedbackLogLocations | ForEach-Object {
try {
# Retrieve the log file content
$LogPath = $_.LogFileLocation
$LogContent = Get-Content -Path $LogPath -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the log file at '$($_.LogFileLocation)'."
$ExitCode = 1
return
}
try {
# Trim the log file content if it exceeds the specified number of comments
if ($LogContent.Count -le $CommentsToKeep) {
Write-Host -Object "The log file at '$LogPath' already has less than or equal to '$CommentsToKeep' comments."
} else {
Set-Content -Path $LogPath -Value ($LogContent | Select-Object -Last $CommentsToKeep) -ErrorAction Stop
Write-Host -Object "Updated the log file at '$LogPath'."
}
} catch {
# Log an error if unable to update the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to update the log file at '$($_.LogFileLocation)'."
$ExitCode = 1
}
}
exit $ExitCode
}
end {
}
Detailed Breakdown
The script is extensive and methodically built for reliability and flexibility. Here’s how it works:
1. Initialization and Validation
- Validates OS version compatibility (Windows 10/Server 2016 and up).
- Checks administrative privileges.
- Validates and sanitizes input parameters such as MaxCommentsToKeep, KeepFeedbackHistory, and custom field names.
2. User Profile Enumeration
- Uses the Get-UserHive function to enumerate all user profiles (domain, local, AzureAD).
- Loads registry hives when needed to extract the appropriate log file paths.
3. Feedback Log Discovery
- Searches standard AppData\Local paths for feedback log files named UserDeviceFeedback.log.
4. Feedback Extraction and Parsing
- Reads log files line-by-line.
- Each line is split into data points like timestamp, username, domain, and base64-encoded feedback message.
- The message is decoded and added to a collection of feedback entries.
5. Updating Custom Fields
- The most recent, uncollected feedback per user is compiled.
- Saves it to the Latest User Feedback (multiline field) if it fits within the 10,000-character limit.
- If enabled, a formatted HTML table of the last n entries is created and saved to the User Feedback History(WYSIWYG field), adhering to a 200,000-character limit.
6. Log File Updates
- Marks processed feedback entries as “collected.”
- Prunes older entries beyond the specified maximum to maintain log size.
7. Character Limit Handling
- Automatically trims older feedback if field limits are exceeded, ensuring data is not truncated mid-record.
This structured approach ensures only new, relevant feedback is collected and avoids redundant processing.
Potential Use Cases
Scenario: A mid-sized MSP manages 500 endpoints across multiple clients. After deploying a performance update, they want to assess its impact on user experience.
- Step 1: Deploy Script 1 across devices to prompt users for feedback.
- Step 2: Use Script 2 to consolidate responses into NinjaOne.
- Step 3: Review responses centrally and escalate devices with poor feedback.
This creates a closed feedback loop without human intervention — streamlining troubleshooting and enhancing customer satisfaction.
Comparisons
Other methods to collect user feedback in Windows with a PowerShell script might include:
- Writing directly to a central file share (risk of permission issues).
- Using Event Logs (complex parsing).
- Emailing logs (not secure or scalable).
Compared to these, NinjaOne integration via custom fields:
- Ensures secure, device-bound data storage.
- Provides easy access in the NinjaOne console.
- Enables automation and reporting via Ninja workflows.
Implications
Automated feedback collection has far-reaching implications. By continuously gathering and archiving feedback, IT departments can:
- Detect performance regressions early.
- Identify recurring issues.
- Justify infrastructure investments with real user sentiment data.
Moreover, storing this information securely within NinjaOne reinforces data compliance and governance standards.
Recommendations
- Run Script 2 on a schedule (e.g., daily) using NinjaOne scheduled scripts.
- Always pair with Script 1 for full functionality.
- Set realistic values for MaxCommentsToKeep to avoid bloated logs.
- Enable KeepFeedbackHistory if long-term trend analysis is required.
- Monitor field sizes to prevent data truncation due to platform limits.
Final Thoughts
This script exemplifies how to collect user feedback in Windows with a PowerShell script and integrate it into an RMM ecosystem like NinjaOne. For IT professionals and MSPs striving to deliver proactive, user-centric support, this automation reduces overhead, improves visibility, and empowers data-driven decision-making. By leveraging NinjaOne’s scripting capabilities and custom fields, feedback becomes not just a checkbox — but a cornerstone of IT service quality.