Metering Application Usage with Asynchronous Event Monitoring

By The Microsoft Scripting Guys

Doctor Scripto at work

Doctor Scripto's Script Shop welds simple scripting examples together into more complex scripts to solve practical system administration scripting problems, often from the experiences of our readers. His contraptions aren't comprehensive or bullet-proof. But they do show how to build effective scripts from reusable code modules, handle errors and return codes, get input and output from different sources, run against multiple machines, and do other things you might want to do in your production scripts.

We hope find these columns and scripts useful – please let us know what you think of them. We'd also like to hear about other solutions to these problems that you've come up with and topics you'd like to see covered here in the future.

For an archive of previous columns, see the Doctor Scripto's Script Shop archive.

On This Page

Metering Application Usage with Asynchronous Event Monitoring
Limiting the time processes can run
Another way to stop processes after a time limit
Measuring application usage on one machine
Measuring application usage on multiple machines
Using asynchronous methods to terminate over-limit applications
Resources

Metering Application Usage with Asynchronous Event Monitoring

Dr. Scripto is always delighted when he gets questions from readers to which he already has a good answer. And he gets even more excited when a reader answers his own question and then asks some more interesting ones.

The initial mail from Ernesto, a Web developer in Colombia, asked: "Can you use WMI to determine how long an application has been running?"

Before Dr. Scripto could get back to him, Ernesto wrote again that he had found a way to do this on the Web, but he had some other questions as well. And so the conversation has continued.

Assiduous readers of this column may recall that we've discussed related questions and written related scripts in four recent columns. Cradle-to-grave management of processes and services has become Dr. Scripto's bread and butter, not to mention his toast and jam.

In "It's 2 a.m. Do you know where your processes are?" we showed how to create processes and semisynchronously monitor the events triggered when those processes stop to calculate their duration on one machine.

Then in "It's 2 a.m. Do you know where your processes are? - The Sequel," we expanded to scripts that can perform that task on more than one machine, still using semisynchronous event handling. This required two scripts, one to start the process on multiple machines and another to monitor it on each machine. This sort of script might be useful for a scenario such as installing patches on multiple machines, where it would be helpful but not essential to know how long each process was running. Still, the need to use two scripts to accomplish one task still struck the Doctor as a bit clunky. Why, he pondered, couldn't we do it in one script?

His pondering bore fruit in two more ponderous columns, "Controlling pest-ware with asynchronous event monitoring" and "Out of Sync: The Return of Asynchronous Event Monitoring," which ventured into the terra nearly incognita of asynchronous event monitoring. But the focus of these columns was on finding undesirable processes and getting rid of them, rather than measuring the duration or usage of processes belonging to applications.

Which brings us to this column. By now you're probably tempted to whisper through gritted teeth, "Doctor, have you considered that your interest in asynchronous events might be approaching an obsession? Isn't it time to find new friends and new interests?" Your concern means a lot to Dr. Scripto, really it does. But hang in there with him just a little bit longer. His infatuation with asynchronicity is not merely because it's one of those empty places on the WMI map marked "Here be monsters" by early explorers.

As we've been discovering, when you try to trap events and perform some other tasks on multiple machines, asynchronous methods are often the most efficient way to get the job done. That's because you can get something started on one machine and move on to the next machine, rather than having to wait around on the first machine until that something is done. Asynchronous methods are commitment-phobic: they flit from host to host sowing their queries, never tarrying to find out what became of their progeny. They leave that task to the old reliable event sink that waits patiently for the query results to come back in. This fickleness — or independence, depending on how you look at it — can come in very handy any time you need a script that monitors goings-on on more than one box.

Limiting the time processes can run

Ernesto said in a follow-up mail that he had found a script on the Web that seemed to do what he was trying to do. He pointed to one by Phill Robinson called "Kill Processes That Have Been Running For More Than A Set Time" on Win32 Scripting, a popular script-sharing site. Dr. Scripto took the liberty of modifying the script to terminate multiple process names and making some other simplifications. This script and the original are designed to be run as a scheduled task at some regular interval.

Hats off to Phill for highlighting the possibilities of the Win32_PerfRawData_PerfProc_Process class (and to Ernesto for finding the script). There are a wealth of other performance counter classes that the Scripting Guys have hitherto neglected. While these classes may also be hidden in the "Here be monsters" zone near the edge of the known scripting world, they may well contain other hidden treasures. And you can't beat that class name: it's got a beat you could almost dance to.

The following script loops through the list of processes to check, querying Win32_PerfRawData_PerfProc_Process for a collection of any processes with the target names. Note that this class uses just the filename of the process without the extension as its name; for example, calc rather than calc.exe. Win32_Process and Task Manager both use the filename and extension as the process name.

It's not obvious looking over the list of properties of Win32_PerfRawData_PerfProc_Process which ones you could use to calculate process duration. Apparently, Phill somehow figured out that you use Timestamp_Object (the current time) minus ElapsedTime (actually the starting time), dividing the difference by Frequency_Object, the frequency in ticks per second of Timestamp_Object (10 million in this case). It doesn't help that the name "ElapsedTime" is a misnomer, but now we know. The values of the first two properties are large numbers of the uint64 type, but they represent FILETIME times, a WMI time format. Fortunately, the script doesn't have to convert them, but can just take the difference to get the time the process has been running.

Once we've figured out which processes are over the time limit, Win32_PerfRawData_PerfProc_Process unfortunately doesn't offer any way to shut them down. But it does have an IDProcess property that contains the unique identifier for that process. So we can use the Get method of SWbemServices to match the PID to the Handle property of Win32_Process, the key process identifier for that class. Then with the Terminate method of Win32_Process, the script can administer the coup de grace.

Another quirk of WMI that this script illustrates is that the Get method doesn't allow spaces in its object path. So

Set objProc = objWMIService.Get _
 "Win32_Process.Handle = '" & objProcess.IDProcess & "'")

raises an error, but

Set objProc = objWMIService.Get _
 ("Win32_Process.Handle='" & objProcess.IDProcess & "'")

hums happily along without incident.

Why? Please, friend, speak softly. If Dr. Scripto latches on to a question like that, he might write four columns on it as well. Even an ostensibly logical technology like WMI needs a few mysteries.

Listing 1: Terminate processes that have been running for a specified time. (Win32_PerfRawData_PerfProc_Process)

Const MAX_TIME = 10 'seconds
arrTargetProcs = Array("calc","notepad","freecell")
'process names without extension
strComputer = "."

Set objWMIService = GetObject("winmgmts:\\" & strComputer)

For Each strTargetProc In arrTargetProcs
  Set colProcesses = objWMIService.ExecQuery _
   ("SELECT * FROM Win32_PerfRawData_PerfProc_Process WHERE Name = '" & _
    strTargetProc & "'")
  If colProcesses.Count > 0 Then
    For Each objProcess In colProcesses
      intProcDur = (objProcess.Timestamp_Object - _
       objProcess.ElapsedTime) / objProcess.Frequency_Object
      If Int(intProcDur) > MAX_TIME Then
        WScript.Echo "Process " & objProcess.Name & " " & _
         objProcess.IDProcess & " over time limit."
        Set objProc = objWMIService.Get _
        'No spaces allowed in Get object path.
         ("Win32_Process.Handle='" & objProcess.IDProcess & "'")
        intReturn = objProc.Terminate
        If intReturn = 0 Then
          WScript.Echo "  Process terminated."
        Else
          WScript.Echo "  Unable to terminate process."
        End If
      Else
        WScript.Echo "Process " & objProcess.Name & " " & _
        objProcess.IDProcess & " not over time limit."
      End If
    Next
  Else
    WScript.Echo "Process " & strTargetProc & " not found."
  End If
Next

Another way to stop processes after a time limit

The previous script works fine, but fine has never been good enough for Dr. Scripto. He couldn't resist writing another script to show how to do the same job with Win32_Process, given all the time he's spent with it lately.

The following script is similar to the previous one. But because Win32_Process has a Terminate method, if a process is found that's over the time limit, the script does not have to instantiate a new class to be able to kill the process.

Win32_PerfRawData_PerfProc_Process works with a start and current time that are both in FILETIME format. This script gets the Win32_Process process creation time in WMI's native DATETIME format and the current time in the VT_DATE format, as retuned by the VBScript Now function.

To compare these two times, the script uses the SWbemDateTime object, a helper object from the WMI Scripting API (available only on Windows XP and Windows Server 2003). SWbemDateTime enables scripts to convert any of the three formats used in WMI — FILETIME, DATETIME and VT_DATE — to another. (For more information on how to work with these date and time formats see the Scripting Techniques > Dates and Times section of the Script Repository.)

With the start and current times in VT_DATE format, the script then uses the VBScript function DateDiff to calculate the difference. The first parameter passed to DateDiff, "s", indicates that it should return seconds; the second and third parameters are variables representing the two dates to subtract.

Listing 2: Terminate processes that have been running for a specified time.
(Win32_Process)

Const MAX_TIME = 10 'seconds
arrTargetProcs = Array("calc.exe","notepad.exe","freecell.exe")
strComputer = "."

Set objWMIService = GetObject("winmgmts:\\" & strComputer)

For Each strTargetProc In arrTargetProcs
  Set colProcesses = objWMIService.ExecQuery _
   ("SELECT * FROM Win32_Process WHERE Name='" & strTargetProc & "'")
  If colProcesses.Count > 0 Then
    For Each objProcess In colProcesses
      Wscript.Echo "Process Name: " & objProcess.Name
      Wscript.Echo "  Process ID: " & objProcess.ProcessID
      vtdNow = Now
      Set objDateTime = CreateObject("WbemScripting.SWbemDateTime")
      objDateTime.Value = objProcess.CreationDate
      vtdCreation = objDateTime.GetVarDate
      Wscript.Echo "  Time Created: " & vtdCreation
      WScript.Echo "  Current Time: " & vtdNow
      intDuration = DateDiff("s", vtdCreation, vtdNow)
      WScript.Echo "  Duration: " & intDuration & " seconds"
      If Int(intDuration) > MAX_TIME Then
        WScript.Echo "  Process " & objProcess.Name & " " & _
         objProcess.ProcessID & " over time limit."
        intReturn = objProcess.Terminate
        If intReturn = 0 Then
          WScript.Echo "  Process terminated."
        Else
          WScript.Echo "  Unable to terminate process."
        End If
      Else
        WScript.Echo "  Process " & objProcess.Name & " " & _
         objProcess.ProcessID & " not over time limit."
      End If
    Next
  Else
    WScript.Echo "Process " & strTargetProc & " not found."
  End If
Next

Measuring application usage on one machine

We now have a couple of different approaches to checking for several processes and terminating them when they run longer than a set time. The shortcomings of these two scripts should already be apparent. They work reasonably well on one machine, but they might not scale well if you wanted to modify them to run on a moderate number of machines.

To use either script in a practical way, you would have to adapt it to write to a log rather than the display, and any logs would remain on each machine until they were collected. Then they would have to be manually added up and analyzed. The interval could be a problem, too. The more frequently the tasks were scheduled to run the more effective they would be, but the more load they would put on the network and the machines where the scripts are run. If the application usage time is not that critical and the script could be scheduled to run every hour, that might reduce the load.

Another issue these scripts don't address is suggested by Ernesto's original question on how to measure the amount of time an application has been running. An IT organization might want to know the total time an application is being used on all the machines where it runs, adding the duration each time the application is used. This might even be required by a licensing agreement.

Here's where asynchronous event handling can come in mighty handy. Dr. Scripto has put together two scripts that show how you might gather information on how long apps are being used on multiple machines and write that information to a central log. He also tracks how many of these uses of each app have exceeded a specified time limit. But he doesn't terminate apps that run long: his previous script shows how that might be done on one machine, but he ran into problems and out of time in attempting to integrate this code into the second monitoring script that runs against multiple machines. Listing 4, as you'll see, is already pretty intricate.

The following script, Listing 3, runs against just one machine to make it easier to see what the asynchronous event handling code is doing. It gets the list of processes to be monitored from a text file, but it outputs only to the display, again for simplicity.

Main logic

Contained in the first part of the script, the main logic sets up a series of counters for each application. This is some new code that Dr. Scripto cooked up to measure three parameters. Each of these parameters collects total usage on all machines:

  • The total duration of usage of each app

  • The number of times each app is used

  • The number of times that each app is used for longer than the time limit. (The time limit can be set by changing the constant MAX_TIME)

Each of the counters is in the form of an array. To give each array the correct number of elements, the code takes the UBound property of the array into which the processes are read, g_arrTargetProcs, and redimensions each counter to that size (the "g_" stands for "global" because this variable is available throughout the script). It then loops through each counter and initializes each element to 0. Alternatively, the counters could have been implemented as Dictionary objects, which are part of the Script Runtime library included with Windows Script. Dr. Scripto chose to try arrays because they're built in to VBScript and seemed to require a bit less code, but if anybody finds that a set of Dictionary objects work better, he's happy to be corrected.

Continuing with script setup, the main logic creates an event sink named "SINK" and binds to WMI. It checks for an error after the latter, which is a more likely contingency on a remote machine, and calls the error-handling subroutine if the connection to WMI can't be established.

The logic then loops through the list of target processes in the array g_arrTargetProcs and runs an asynchronous event query for each. The query requests any events triggered by the deletion (__InstanceDeletionEvent) of instances of Win32_Process objects that have the same Name property as the target process. Any notifications for these queries will be returned to the event sink just set up. As we've said in previous columns, if performance is a potential issue, you might want to experiment with the number of seconds expressed in the "WITHIN 10" clause of the query: if running time only needs to be collected to the minute, for example, you might be able to lengthen the polling interval.

The script then waits for the monitoring period specified with the constant MON_TIME – because the constant is set in seconds while WScript.Sleep expects a figure in milliseconds, we have to multiply the MON_TIME by 1,000. As the script waits, whenever a target process ends, an event is sent to the event sink, which gets information from the event and calculates its duration.

When the monitoring period ends, the script displays the final tally of the three counters: the total duration of usage, number of uses and number of uses over the time limit for each application.

SINK_OnObjectReady

This event sink subroutine handles the OnObjectReady event for the asynchronous query. We've talked about how this works in previous columns.

This subroutine uses the SWbemDateTime object of the WMI Scripting API for two date/time conversions:

  • the CreationDate property of the current instance of Win32_Process from DATETIME to VT_DATE format.

  • the TIME_CREATED property of objLatestEvent from FILETIME to VT_DATE format.

With both times in VT_DATE format, we can now use the built-in VBScript function DateDiff to take the difference between the two, as we did in Listing 2. Then we compare the duration against the time limit, MAX_TIME, and display that information.

Now, with the calculated duration, we can increment the duration counter, g_arrTimeCounters, by the duration for this particular target process. We also increment by one the usage counter, g_arrFreqCounters, and, if the duration is over the limit, the over-limit counter, g_arrOverCounters.

ReadTextFile

This function is used in this script to read the contents of the process list file into an array. Its functionality has been explained in previous columns.

HandleError

This subroutine handles the error if the script is unable to bind to WMI on the target machine. It has been explained in previous columns.

Listing 3: Monitor usage of specified applications on one machine

On Error Resume Next

'******************************************************************************
'Change block - change values to fit local environment.
strProcList = "proclist.txt"
Const MON_TIME = 30 'seconds script should run
Const MAX_TIME = 10 'seconds - time limit on processes
'******************************************************************************

Dim g_arrTimeCounters() 'time counters for each app
Dim g_arrFreqCounters() 'frequency of usage counters for each app
Dim g_arrOverCounters() 'over-limit counters for each app
strComputer = "."
g_arrTargetProcs = ReadTextFile(strProcList)

WScript.Echo vbCrLf& "Target processes:"
For Each strTargetProc In g_arrTargetProcs
  WScript.Echo strTargetProc
Next

'Redim counter arrays to same size as target process array
intUBound = UBound(g_arrTargetProcs)
Redim g_arrTimeCounters(intUBound)
Redim g_arrFreqCounters(intUBound)
Redim g_arrOverCounters(intUBound)

'Initialize all counters to 0.
For Each intTimeCounter In g_arrTimeCounters
  intTimeCounter = 0
Next
For Each intFreqCounter In g_arrFreqCounters
  intFreqCounter = 0
Next
For Each intOverCounter In g_arrOverCounters
  intOverCounter = 0
Next

Set SINK = WScript.CreateObject("WbemScripting.SWbemSink","SINK_")

WScript.Echo vbCrLf & "Host: " & strComputer

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")

If Err = 0 Then

  For Each strTargetProc In g_arrTargetProcs
    objWMIService.ExecNotificationQueryAsync SINK, _
     "SELECT * FROM __InstanceDeletionEvent WITHIN 10 " & _
     "WHERE TargetInstance ISA 'Win32_Process' " & _
     "AND TargetInstance.Name = '" & strTargetProc & "'"
   Next

  Wscript.Echo vbCrLf & "In monitoring mode for " & (MON_TIME/60) & _
   " minutes ... Ctrl+C to end"

  WScript.Sleep MON_TIME * 1000

  WScript.Echo "Finish Time: " & Now
  WScript.Echo vbCrLf & "Application usage:" & vbCrLf
  For i = 0 To UBound(g_arrTargetProcs)
    WScript.Echo vbCrLf & "Application: " & g_arrTargetProcs(i)
    WScript.Echo "  Total Duration: " & CInt(g_arrTimeCounters(i)) & " seconds"
    WScript.Echo "  Number of Uses: " & CInt(g_arrFreqCounters(i))
    WScript.Echo "  Number of Uses over Time Limit: " & _
     CInt(g_arrOverCounters(i))
  Next

Else

  HandleError strComputer, "Unable to bind to WMI on host."

End If

'******************************************************************************

Sub SINK_OnObjectReady(objLatestEvent, objAsyncContext)
'Trap asynchronous events.

Wscript.Echo VbCrLf & "Process Name: " & objLatestEvent.TargetInstance.Name
Wscript.Echo "Process ID: " & objLatestEvent.TargetInstance.ProcessId

Set objDateTime1 = CreateObject("WbemScripting.SWbemDateTime")
objDateTime1.Value = objLatestEvent.TargetInstance.CreationDate
vtmCreated = objDateTime1.GetVarDate
Wscript.Echo "  Time created: " & vtmCreated

Set objDateTime2 = CreateObject("WbemScripting.SWbemDateTime")
objDateTime2.SetFileTime objLatestEvent.TIME_CREATED, False
vtmDeleted = objDateTime2.GetVarDate
WScript.Echo "  Time deleted: " & vtmDeleted

intDuration = DateDiff("s", vtmCreated, vtmDeleted)
WScript.Echo "  Duration: " & intDuration & " seconds"

If intDuration > MAX_TIME Then
  Wscript.Echo "  Over time limit"
Else
  Wscript.Echo "  Not over time limit"
End If

'Increment time and frequency counters for app trapped.
intCount = 0
For intCount = 0 to UBound(g_arrTargetProcs)
  If g_arrTargetProcs(intCount) = objLatestEvent.TargetInstance.Name Then
    g_arrTimeCounters(intCount) = g_arrTimeCounters(intCount) + intDuration
    g_arrFreqCounters(intCount) = g_arrFreqCounters(intCount) + 1
    If intDuration > MAX_TIME Then
      g_arrOverCounters(intCount) = g_arrOverCounters(intCount) + 1
    End If
  End If
Next

End Sub

'******************************************************************************

Function ReadTextFile(strFileName)
'Read lines of text file and return array with one element for each line.

On Error Resume Next

Const FOR_READING = 1

Set objFSO = CreateObject("Scripting.FileSystemObject")
If objFSO.FileExists(strFilename) Then
  Set objTextStream = objFSO.OpenTextFile(strFilename, FOR_READING)
Else
  Wscript.Echo VbCrLf & "Input text file " & strFilename & " not found."
  WScript.Quit(1)
End If

If objTextStream.AtEndOfStream Then
  Wscript.Echo VbCrLf & "Input text file " & strFilename & " is empty."
  WScript.Quit(2)
End If

arrLines = Split(objTextStream.ReadAll, vbCrLf)
objTextStream.Close

ReadTextFile = arrLines

End Function

'******************************************************************************

Sub HandleError(strComputer, strMsg)
'Handle errors.

WScript.Echo VbCrLf & "ERROR on " & strHost & VbCrLf & _
 "  Number: " & Err.Number & VbCrLf & _
 "  Description: " & Err.Description & VbCrLf & _
 "  Source: " & Err.Source & VbCrLf & _
 "  Explanation: " & strMsg
Err.Clear

End Sub

Measuring application usage on multiple machines

The next script, Listing 4, runs against multiple machines listed in a text file, which is a more likely scenario in an IT shop of any size. Like Listing 3, Listing 4 again gets the list of processes to be monitored from a text file, but this time it also outputs the results to a plain-text log file. This means that the entire record of the script's activity and its final summary end up on the administrative workstation.

A few caveats:

  • The final summary is written to the log file only if the script is allowed to run for the full monitoring time specified in the constant MON_TIME. If you press Ctrl+C to end the script before the time is up, the log will contain a record of any scripts that have been terminated up to that point, but no final summary.

  • Only applications started and stopped while the script is running are counted. To capture durations of processes already running when the script started or still running when it times out, it should not be too hard to add subroutines to handle each of these contingencies.

  • The final summary doesn't break out application usage by machine. If this were important to know, you might be able to add this to the counter code somehow. But at that level of complexity, you might want to go to Dictionary objects or even an ADO disconnected recordset.

To transform Listing 3 into a script that runs against multiple machines, only a few changes are necessary. Throughout the script, the output is handled differently: each piece of output is first written into a string variable and then the variables are displayed with WScript.Echo and also output to the log file with WriteTextFile.

Main logic

We've added a For Each loop that iterates through each machine in the array arrComputers. Within the loop, the script attempts to bind to WMI on each computer and calls QueryProcess for that computer if successful or HandleError if not successful.

QueryProcess

In this script, we've taken the code that runs the asynchronous query and packaged it in a subroutine that's called for each computer. QueryProcess uses the final parameter of ExecNotificationQueryAsync, here named objContext, to pass the name of the machine on to the event sink.

SINK_OnObjectReady

The event sink now reads the second parameter passed to it by ExecNotificationQueryAsync, here called objAsyncContext, to identify the name of the machine on which the current event is occurring.

WriteTextFile

This subroutine, which we've explained in previous columns, appends output sent to it to the output log file.

SecsToHours

We've added this function to make the output more readable. If a process is running for minutes or hours, the total number of seconds is not the user-friendliest way to express it. This function converts an integer representing seconds to a string that converts the seconds to hours, minutes and seconds, and spells this out.

Listing 4:

On Error Resume Next

'******************************************************************************
'Change block - change values to fit local environment.
strCompList = "complist.txt"
strProcList = "proclist.txt"
g_strOutputFile = "applog.txt"
Const MON_TIME = 60 'seconds script should run
Const MAX_TIME = 10 'seconds - time limit on processes
'******************************************************************************

Dim g_arrTimeCounters() 'time counters for each app
Dim g_arrFreqCounters() 'frequency of usage counters for each app
Dim g_arrOverCounters() 'over-limit usage counters for each app
arrComputers = ReadTextFile(strCompList)
g_arrTargetProcs = ReadTextFile(strProcList)

'Redim counter arrays to same size as target process array
intUBound = UBound(g_arrTargetProcs)
Redim g_arrTimeCounters(intUBound)
Redim g_arrFreqCounters(intUBound)
Redim g_arrOverCounters(intUBound)

'Initialize all counters to 0.
For Each intTimeCounter In g_arrTimeCounters
  intTimeCounter = 0
Next
For Each intFreqCounter In g_arrFreqCounters
  intFreqCounter = 0
Next
For Each intOverCounter In g_arrOverCounters
  intOverCounter = 0
Next

strMessageHeader = vbCrLf & "Target processes:" & vbCrLf
For Each strTargetProc In g_arrTargetProcs
  strMessageHeader = strMessageHeader & strTargetProc & vbCrLf
Next
strMessageHeader = strMessageHeader & vbCrLf & "Querying machines on list:"
WScript.Echo strMessageHeader
WriteTextFile g_strOutputFile, strMessageHeader

Set SINK = WScript.CreateObject("WbemScripting.SWbemSink","SINK_")

For Each strComputer In arrComputers
  strMessageHost = vbCrLf & "Host: " & strComputer
  WScript.Echo strMessageHost
  WriteTextFile g_strOutputFile, strMessageHost

  Set g_objWMIService = GetObject("winmgmts:" _
   & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
  If Err = 0 Then
    QueryProcess strComputer
  Else
    HandleError strComputer, "Unable to bind to WMI on host."
  End If
Next

strMessageMon = vbCrLf & "Monitoring start time: " & Now & vbCrLf & _
 "In monitoring mode for " & SecsToHours(MON_TIME) & vbCrLf & _
 " ... Ctrl+C to end" & vbCrLf & _
 "----------------------------------------------------"
WScript.Echo strMessageMon
WriteTextFile g_strOutputFile, strMessageMon

WScript.Sleep MON_TIME * 1000

strMessageSum = _
 vbCrLf & "--------------------------------------------------  " & _
 vbCrLf & "Monitoring finish time: " & Now & vbCrLf & _
 vbCrLf & "Application usage:"
For i = 0 To UBound(g_arrTargetProcs)
  strMessageSum = strMessageSum & vbCrLf & vbCrLf & "Application: " & _
  g_arrTargetProcs(i) & vbCrLf & _
  "  Total Duration: " & SecsToHours(CInt(g_arrTimeCounters(i))) & vbCrLf & _
  "  Number of Uses: " & CInt(g_arrFreqCounters(i)) & vbCrLf & _
  "  Number of Uses over Time Limit: " & CInt(g_arrOverCounters(i))
Next
WScript.Echo strMessageSum
WriteTextFile g_strOutputFile, strMessageSum

'******************************************************************************

Sub QueryProcess(strHost)

On Error Resume Next

Set objContext = CreateObject("WbemScripting.SWbemNamedValueSet")
objContext.Add "hostname", strHost

'Query host asynchronously for process creation events.
For Each strTargetProc In g_arrTargetProcs
  strQuery = "SELECT * FROM __InstanceDeletionEvent WITHIN 10 " & _
   "WHERE TargetInstance ISA 'Win32_Process' " & _
   "AND TargetInstance.Name = '" & strTargetProc & "'"
  g_objWMIService.ExecNotificationQueryAsync SINK, _
   strQuery, , , , objContext
Next
If Err = 0 Then
  strMessageTNP = vbCrLf & "  Monitoring target processes."
  WScript.Echo strMessageTNP
  WriteTextFile g_strOutputFile, strMessageTNP
Else
  HandleError strHost, "  Unable to run asynchronous query."
End If

End Sub

'******************************************************************************

Sub SINK_OnObjectReady(objLatestEvent, objAsyncContext)
'Trap asynchronous events.

Set objAsyncContextItem = objAsyncContext.Item("hostname")
strHost = objAsyncContextItem.Value
strProcName = objLatestEvent.TargetInstance.Name
strProcID = objLatestEvent.TargetInstance.ProcessId

Set objDateTime1 = CreateObject("WbemScripting.SWbemDateTime")
objDateTime1.Value = objLatestEvent.TargetInstance.CreationDate
vtmCreated = objDateTime1.GetVarDate

Set objDateTime2 = CreateObject("WbemScripting.SWbemDateTime")
objDateTime2.SetFileTime objLatestEvent.TIME_CREATED, False
vtmDeleted = objDateTime2.GetVarDate

intDuration = DateDiff("s", vtmCreated, vtmDeleted)

If intDuration > MAX_TIME Then
  strTimeLimit = "  Over time limit"
Else
  strTimeLimit = "  Not over time limit"
End If

strSinkData = VbCrLf & "Computer Name: " & strHost & vbCrLf & _
 "Process Name: " & strProcName & VbCrLf & _
 "Process ID: " & strProcID & VbCrLf & _
 "  Time created: " & vtmCreated & VbCrLf & _
 "  Time deleted: " & vtmDeleted & VbCrLf & _
 "  Duration: " & SecsToHours(intDuration) & VbCrLf & _
 strTimeLimit
WScript.Echo strSinkData
WriteTextFile g_strOutputFile, strSinkData

'Increment time and frequency counters for app.
intCount = 0
For intCount = 0 to UBound(g_arrTargetProcs)
  If g_arrTargetProcs(intCount) = objLatestEvent.TargetInstance.Name Then
    g_arrTimeCounters(intCount) = g_arrTimeCounters(intCount) + intDuration
    g_arrFreqCounters(intCount) = g_arrFreqCounters(intCount) + 1
    If intDuration > MAX_TIME Then
      g_arrOverCounters(intCount) = g_arrOverCounters(intCount) + 1
    End If
  End If
Next

End Sub

'******************************************************************************

Function ReadTextFile(strFileName)
'Read lines of text file and return array with one element for each line.

On Error Resume Next

Const FOR_READING = 1

Set objFSO = CreateObject("Scripting.FileSystemObject")
If objFSO.FileExists(strFilename) Then
  Set objTextStream = objFSO.OpenTextFile(strFilename, FOR_READING)
Else
  strRTFMessage1 = VbCrLf & "Input text file " & strFilename & " not found."
  Wscript.Echo strRTFMessage1
  WriteTextFile g_strOutputFile, strRTFMessage1
  WScript.Quit(1)
End If

If objTextStream.AtEndOfStream Then
  strRTFMessage2 = VbCrLf & "Input text file " & strFilename & " is empty."
  Wscript.Echo strRTFMessage2
  WriteTextFile g_strOutputFile, strRTFMessage2
  WScript.Quit(2)
End If

strFileContents = objTextStream.ReadAll
arrLines = Split(strFileContents, vbCrLf)

objTextStream.Close

ReadTextFile = arrLines

End Function

'******************************************************************************

'Write or append data to text file.
Sub WriteTextFile(strFileName, strOutput)

On Error Resume Next

Const FOR_APPENDING = 8

'Open text file for output.
Set objFSO = CreateObject("Scripting.FileSystemObject")
If objFSO.FileExists(strFileName) Then
  Set objTextStream = objFSO.OpenTextFile(strFileName, FOR_APPENDING)
Else
  Set objTextStream = objFSO.CreateTextFile(strFileName)
End If

'Write data to file.
objTextStream.WriteLine strOutput

objTextStream.Close

End Sub

'******************************************************************************

Function SecsToHours(intTotalSecs)
'Convert time in seconds to hours, minutes, seconds and return in string.

intHours = intTotalSecs \ 3600
intMinutes = (intTotalSecs Mod 3600) \ 60
intSeconds = intTotalSecs Mod 60

SecsToHours = intHours & " hours, " & intMinutes & " minutes, " & _
 intSeconds & " seconds"

End Function

'******************************************************************************

Sub HandleError(strHost, strMsg)
'Handle errors.

strError = VbCrLf & "  ERROR on " & strHost & VbCrLf & _
 "  Number: " & Err.Number & VbCrLf & _
 "  Description: " & Err.Description & VbCrLf & _
 "  Source: " & Err.Source & VbCrLf & _
 "  Explanation: " & strMsg
WScript.Echo strError
WriteTextFile g_strOutputFile, strError
Err.Clear

End Sub

Using asynchronous methods to terminate over-limit applications

We now have a moderately robust script that can tell us how much each application is being used, and how many of those uses run over the time limit. But it doesn't terminate those over-limit processes, which is one of the things Ernesto wanted to do.

So Dr. Scripto started looking around for ways to incorporate a Terminator into the script, and of course immediately gravitated towards the most complex idea. The problem he ran into is that the kind of timer using WScript.Sleep that he's been using to measure the time limit holds up the script each time it’s run. So he cast about for an asynchronous solution that would move the timer out of the main execution path of the script.

He tried writing a test script that calls the GetAsync method of SWbemServices, the asynchronous counterpart of Get. The script queries asynchronously for __InstanceCreationEvent events involving Win32_Process. Then when it traps one, it creates a second event sink, SINKTIME, and gets a reference to the process just trapped by the first event sink with a GetAsync call, using the Handle property of Win32_Process to identify the process in question.

But so far this script doesn't return accurate process duration times: something seems to be delaying the asynchronous callbacks somewhere. He's also been working on a different way of killing over-limit processes, using a timer that periodically calls some of the code from Listing 2. But the path from interesting ideas to working code has been rocky so far. And so the Doctor is still in the Script Crypt tinkering, muttering and cursing under his breath.

If he comes up with an elegant solution, we'll publish it in a future column. Until then, if any readers can find a good way to solve this problem and want to pass it along, we'd be interested in seeing how you do it. And it might help Dr. Scripto get some sleep.

Resources