Overview
In this article, you will learn how to use the Windows Event Log from Visual FoxPro. While you
can use Windows Scripting Host (WSH) to read and write to the Windows Event Log, WSH
could be disabled due to today s security requirements. Therefore, it is necessary to use the
Win32 API to access the log. Using several API calls, you will learn how to read, write, and
maintain the log.
Logging Overview
Capturing error conditions is an important part of a robust application. Historically, Visual
FoxPro applications have used tables (DBFs) or text files to capture error information. However,
other types of logging are available. These include XML files or the Windows Event Log.
Using the Windows Event Log is desirable because other file types use proprietary formats,
different user interfaces for reporting, and they cannot be merged with logs from other
applications. The Windows Event Log is a standard format, stored in a centralized location and
single user interface can be used to view log information from a variety of programs. Also, third
party software can be purchased that will report on entries stored in the Windows Event Log.
In addition to error conditions, the Windows Event Log can be used to store information such as
a user logging on, opening a database, starting a file transfer, or other information. The Windows
Event Log should not be used as a tracing tool or to record transactional information such as
database changes.
The Windows Event Log actually contains several types of logs. The standard logs and what
they are used for are listed in Table 1. Under Windows 2000 and later you may find User
Defined types. I will talk more about these later in this document. Your programs will make
registry entries under the Application or a User Defined type.
The Windows Event Log is actually a group of files. Each file represents a different log type.
The location of these files is specified by Registry entries.
Date
Time
Username: The user that wrote the entry.
Computer: The name of the computer that wrote the error. Generally, you will use the
local event log. However, if you have the proper rights, you can log information to a
server or other computer.
Source: The application, module, or component that wrote the event.
Message Type: Can be Success, Error, Warning, Information, Audit Success or Audit
Failure.
Category: Specified by a message file. Message files are discussed later in this document
Event ID: Specified by a message file.
Description ID: Specified by a message file.
Binary Data
The primary way to view information from the event log is the Event Log Viewer (Figure 1).
Under Windows 2000 or later, you access the Viewer from the Administrative Tools applet of
Windows Control Panel.
Figure 1. The Event Viewer is used to view and manage the Windows Event Log.
When you double-click on an entry, the Event Properties dialog (Figure 2) for that event is
displayed. You can see from Figure XX that not all the information is required. For example, the
Category and User are not specified. The Description is actually loaded from a message file.
Step three consists of several Win32 API calls and is discussed in detail later in this document.
To create a resource DLL you will need the Resource Compiler, Message Compiler, and Linker.
These tools ship with Visual Studio, the Microsoft Platform SDK, and other developer tools. I
did an Internet search and got hits for free tools that may give you the same functionality.
1. Create a text file for the messages. This example uses FoxMessages.mc.
LanguageNames =
(
English = 0x0409:Messages_ENU
)
;////////////////////////////////////////
;// Eventlog categories
;//
;// These always have to be the first entries in a message file
MessageId = 1
SymbolicName = CATEGORY_ONE
Severity = Success
Language = English
First category event
.
MessageId = 2
SymbolicName = CATEGORY_TWO
Severity = Success
Language = English
Second category event
.
;////////////////////////////////////////
;// Events
MessageId = 1000
SymbolicName = HELLO
Language = English
Hello World!
.
MessageId = 1001
SymbolicName = GREETING
Language = English
Hello %1. How are you?
.
MessageId = 1002
SymbolicName = GENERIC1
Language = English
%1
.
MessageId = 1003
SymbolicName = GENERIC2
Language = English
Message 1: %1 Message 2: %2
2. In the Windows Command Interpreter, compile the message file using the Message
Compiler.
mc FoxMessages.mc
3. In the Windows Command Interpreter, compile the resource file using the Resource
Compiler.
rc FoxMessages.rc
The dll parameter tells the linker to create a .dll file (FoxMessages.dll) and noentry
says there will not be entry point. This is required because the linker is not creating a
Win32 dll, but rather a resource dll.
The resource dll is now ready to use. The sample code that accompanies this article includes the
batch file Make.bat that does all three steps for you.
1. Run Regedit and drill down until you reach the above key (Figure 3)
Figure 3. The Registry Editor is used to view and manage entries in the Windows registry.
2. Right-click on Application and select New | Key from the context menu. Enter
FoxMessages as the key.
4. Double-click EventMessageFile. The Edit String dialog (Figure 4) is displayed. Enter the
path to your resource dll and click OK.
Figure 4. You can add or change an entry in the registry from the Edit String dialog.
Figure 5. You can add or edit a DWORD in the registry using the Edit DWORD Value dialog.
7. Add an entry for CategoryMessageFile. The Value will be the same path and file name
you entered for EventMessageFile.
8. Add another entry for CategoryCount. The Value will be 2, as that is the number of
categories in the message file.
You have completed the registry entries needed for your application. Next, I ll show you how to
write to the Windows Event Log.
It is common when using the Win32 API to make the function call then call another function to
check if an error occurred. The function to call is GetLastError( ). Most Win32 API functions set
the error condition when they fail. However, some set the error code when they succeed.
Because of this, you should always call GetLastError( ) after making a Win32 API call.
Before you can write to an event source, you need to register it. The function returns a handle to
the event source or NULL if an error occurs. Pass a NULL or empty string as the first parameter
to use the Windows Event Log on the local computer.
STRING SourceName
When you finish with log, call DeregisterEventSource( ), which has a single parameter,
hEventLog. This is the handle that was returned by RegisterEventSource( ).
The ReportEvent( ) function actually records the event into the log. If the message is
successfully recorded to the log, the return value is non-zero. If the function fails, a 0 is returned.
#DEFINE EVENTLOG_SUCCESS 0
#DEFINE EVENTLOG_ERROR_TYPE 1
#DEFINE EVENTLOG_WARNING_TYPE 2
#DEFINE EVENTLOG_INFORMATION_TYPE 4
#DEFINE EVENTLOG_AUDIT_SUCCESS 8
#DEFINE EVENTLOG_AUDIT_FAILURE 10
When reading the event log, the first thing you need to do is call OpenEventLog( ).
If the function succeeds, it returns a handle to the event log. If it fails, it returns NULL. The
parameters are listed in Table 5.
Table 5. A listing of the parameters for the OpenEventLog function.
Parameters Description
UNCServerName The server name where the event is logged. If logging to the local machine,
pass an empty string.
SourceName The source name that is saved to the Registry. Earlier, I named the source,
FoxMessage .
When you finish using the event log, you should call the CloseEventLog( ) function.
Once the event log is open, it can be read. The event log API allows you to read a single entry at
a time using the ReadEventLog( ) function.
If successful, a non-zero value is returned. Zero is returned if the function fails. The parameters
are listed in Table 6.
#DEFINE EVENTLOG_SEQUENTIAL_READ 1
#DEFINE EVENTLOG_SEEK_READ 2
#DEFINE EVENTLOG_FORWARDS_READ 4
#DEFINE EVENTLOG_BACKWARDS_READ 8
Now that you ve seen the primary functions in the Event Log API, it s time to see some
additional functions you can do in the Windows Event Log. The first tells you how many records
are in the event log.
The return value will be nonzero if the function succeeds or zero if it fails. Table 7 lists the
parameters.
GetOldestEventLogRecord( ) gets the record number for the oldest event in the log. It returns a
nonzero value if successful or zero if it fails. Table 8 lists the parameters.
That completes the actual event log API calls. There are some additional Win32 API functions
that you ll need to use. The first of these is GlobalAlloc( ), which allocates memory on the heap.
If the function succeeds, it returns a handle to the allocated memory. If it fails, it returns NULL.
The following line defines the value to pass for the Flags parameter:
Since memory is allocated, is must also be deallocated. That is the purpose of GlobalFree( ). The
single parameter is the handle to the memory, as returned by GlobalAlloc( ). If GlobalFree( )
succeeds in deallocating the memory, it returns NULL. Any other value indicates failure.
The strings that are used for the @Strings parameter of the ReportEvent function need to be
copied from Visual FoxPro memory into the allocated memory. Keep in mind that the Event API
function requires a C++ array string. By copying the memory and doing some other hocus-pocus
that I will explain later, the VFP string will look like a C++ array string. The CopyMemory( )
function will handle this for us.
This function has no return value. The parameters are listed in Table 10.
One of the optional parameters of the ReportEvent( ) function is UserSid, which is the User s
Security ID. The LookupAccountSid( ) function gets that information for you.
A nonzero return value indicates success, zero indicates failure. Table 11 lists the parameters.
The next function, LoadLibraryEx( ) maps an executable module into the address space of the
calling process. For the Windows Event Log, it is used to load the message file. Here is the
syntax:
If the function call succeeds, the return value is a handle to the mapped executable module. If it
fails, NULL is returned. Table 12 lists the parameters.
Parameters Description
LibFileName The name of the executable (DLL or EXE) module.
RESERVED Must be NULL
Flags The action to take when loading the module. For event log usage, the only
value you need to use here is to load the library as a datafile.
After you finish using the library, you need to release it. The FreeLibrary( ) function will do this.
It takes a single parameter, hLibModule, which is the return value of LoadLibraryEx( ). If the
function succeeds, it returns a nonzero value. Any other return value indicates failure.
You will need to format the message string. The FormatMessageString( ) function does this.
FormatMessage( ) returns the number of bytes in the output buffer. If it fails, it returns a zero.
Table 13 lists the parameters.
Table 13. A listing parameters for the FormatMessage function.
Parameters Description
Flags A series of bit flags that specify aspects of the formatting process and how
to interpret the Source parameter. See the FORMAT_MESSAGE constants
below.
Source Specifies the location of the message definition. This will be the resource
file loaded by LoadLibraryEx.
MessageId The Message Id from the Message file.
LanguageId The language Id. You can use 0 if no language is specified.
@Buffer Used to return the formatted message.
BufferSize The maximum number of bytes that can be stored in @Buffer.
@Arguments Values that are used as insert values in the message.
If the function succeeds, the return value is the number of characters stored in the destination
buffer. If the number of characters is greater than the size of the destination buffer, the return
value is the size of the buffer required to hold the expanded strings. If the function fails, the
return value is zero. Table 14 lists the parameters.
That completes the Win32 API functions. However, there is still just a bit more to cover before
looking at the full code. I haven t completely showed you how to do the conversion from a VFP
string to the C++ array string. The following VFP functions will do this:
LPARAMETERS lnLongval
lcRetstr = ""
FOR lnI = 24 TO 0 STEP -8
lcRetstr = CHR(INT(lnLongval / (2 ^ lnI))) + lcRetstr
lnLongval = MOD(lnLongval, (2 ^ lnI))
NEXT
RETURN lcRetstr
LPARAMETERS lcLongstr
lnRetval = 0
FOR lnI = 0 TO 24 STEP 8
lnRetval = lnRetval + (ASC(lcLongstr) * (2 ^ lnI))
lcLongstr = RIGHT(lcLongstr, LEN(lcLongstr) - 1)
NEXT
RETURN lnRetval
#define EVENTLOG_SUCCESS 0
#define EVENTLOG_ERROR_TYPE 1
#define EVENTLOG_WARNING_TYPE 2
#define EVENTLOG_INFORMATION_TYPE 4
#define EVENTLOG_AUDIT_SUCCESS 8
#define EVENTLOG_AUDIT_FAILURE 10
STRING @Strings, ;
INTEGER RawData
IF lnHandle > 0
* No parameter
lnRetVal = ReportEvent(lnHandle, EVENTLOG_SUCCESS, 1, 1000, 0, 0, 0,
NULL, 0)
IF lnRetVal = 0
MESSAGEBOX("ReportEvent Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
* Single parameter
lcName = "Fred"
pName = GlobalAlloc(GMEM_ZEROINIT, LEN(lcName) + 1)
CopyMemory(pName, lcName, LEN(lcName))
lcStrings = LongToStr(pName)
lnRetVal = ReportEvent(lnHandle, EVENTLOG_SUCCESS, 2, 1001, 0, 1, 0,
@lcStrings, 0)
IF lnRetVal = 0
MESSAGEBOX("ReportEvent Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
IF pName > 0
lnRet = GlobalFree(pName)
ENDIF
* Multiple parameters
lcMessage1 = "This is the first message."
lcMessage2 = "This is another message!"
pMessage1 = GlobalAlloc(GMEM_ZEROINIT, LEN(lcMessage1) + 1)
pMessage2 = GlobalAlloc(GMEM_ZEROINIT, LEN(lcMessage2) + 1)
CopyMemory(pMessage1, lcMessage1, LEN(lcMessage1))
CopyMemory(pMessage2, lcMessage2, LEN(lcMessage2))
lcMessages = LongToStr(pMessage1) + LongToStr(pMessage2)
lnRetVal = ReportEvent(lnHandle, EVENTLOG_SUCCESS, 1, 1003, 0, 2, 0,
@lcMessages, 0)
IF lnRetVal = 0
MESSAGEBOX("ReportEvent Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
IF pMessage1 > 0
lnRet = GlobalFree(pMessage1)
ENDIF
IF pMessage2 > 0
lnRet = GlobalFree(pMessage2)
ENDIF
lnRetVal = DeregisterEventSource(lnHandle)
ELSE
MESSAGEBOX("RegisterEventSource Error: " ;
+ ALLTRIM(STR(GetLastError())))
ENDIF
RETURN
******************
* From KB Article ID: Q181289
******************
FUNCTION LongToStr
* The following function converts a long integer to an ASCII
* character representation of the passed value in low-high format.
* Passed: 32-bit non-negative numeric value (lnLongval)
* Returns: ascii character representation of passed value in low-high
* format
LPARAMETERS lnLongval
LOCAL lnI, lcRetstr
lcRetstr = ""
FOR lnI = 24 TO 0 STEP -8
lcRetstr = CHR(INT(lnLongval / (2 ^ lnI))) + lcRetstr
lnLongval = MOD(lnLongval, (2 ^ lnI))
NEXT
RETURN lcRetstr
******************
FUNCTION StrToLong
* Convert a string in low-high format to a long integer.
* Passed: 4-byte character string (lcLongstr) in low-high ASCII format
* Returns: long integer value
LPARAMETERS lcLongstr
LOCAL lnI, lnRetval
lnRetval = 0
FOR lnI = 0 TO 24 STEP 8
lnRetval = lnRetval + (ASC(lcLongstr) * (2 ^ lnI))
lcLongstr = RIGHT(lcLongstr, LEN(lcLongstr) - 1)
NEXT
RETURN lnRetval
As you can see from this example, the actual FoxPro code is fairly simple. The key to using the
event log is defining the API calls and converting FoxPro data types to the C++ data types
needed for the calls.
#DEFINE EVENTLOG_SEQUENTIAL_READ 1
#DEFINE EVENTLOG_SEEK_READ 2
#DEFINE EVENTLOG_FORWARDS_READ 4
#DEFINE EVENTLOG_BACKWARDS_READ 8
#DEFINE EVENTLOG_SUCCESS 0
#DEFINE EVENTLOG_ERROR_TYPE 1
#DEFINE EVENTLOG_WARNING_TYPE 2
#DEFINE EVENTLOG_INFORMATION_TYPE 4
#DEFINE EVENTLOG_AUDIT_SUCCESS 8
#DEFINE EVENTLOG_AUDIT_FAILURE 10
STRING SystemName, ;
STRING @Sid, ;
STRING @NAME, ;
STRING @cbName, ;
STRING @ReferencedDomainName, ;
STRING @cbReferencedDomainName, ;
STRING @peUse
**************************************
LOCAL lcUNCSourceName, loReg, hEventLog, lcNumberOfRecords, lnRetVal, ;
lcOldestRecord, lcBuffer, lcBytesRead, lcMinNumberOfBytesNeeded
lcUNCSourceName = "C:\"
* lpUNCSourceName = "3MHIS"
hEventLog = OpenEventLog(NULL, lcUNCSourceName)
IF hEventLog = 0
MESSAGEBOX("OpenEventLog Error: " + ALLTRIM(STR(GetLastError())))
RETURN
ENDIF
lnRetVal = ReadEventLog(hEventLog, ;
BITOR(EVENTLOG_SEQUENTIAL_READ, ;
EVENTLOG_FORWARDS_READ), ;
0, ;
@lcBuffer, ;
LEN(lcBuffer), ;
@lcBytesRead, ;
@lcMinNumberOfBytesNeeded)
IF GetLastError() = ERROR_INSUFFICIENT_BUFFER
lcBuffer = SPACE(StrToLong(lcMinNumberOfBytesNeeded))
lcBytesRead = SPACE(4)
lcMinNumberOfBytesNeeded = SPACE(4)
lnRet = ReadEventLog(hEventLog, ;
BITOR(EVENTLOG_SEQUENTIAL_READ, ;
EVENTLOG_FORWARDS_READ), ;
0, ;
@lcBuffer, ;
LEN(lcBuffer), ;
@lcBytesRead, ;
@lcMinNumberOfBytesNeeded)
IF lnRet = 0
MESSAGEBOX("ReadEventLog Error: " + ALLTRIM(STR(GetLastError())))
EXIT
ELSE
lnRetVal = CloseEventLog(hEventLog)
IF lnRetVal = 0
MESSAGEBOX("CloseEventLog Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
RETURN
****************************************************
FUNCTION ParseLogRecord
LPARAMETERS tcBuffer, tcUNCSourceName, toReg
LOCAL lcMessage, lnCounter, lnDataLen, pBuffer
lnCounter = lnCounter + 1
*************************************************
FUNCTION GetEventType
LPARAMETERS tcBuffer
LOCAL lcRetVal, lnEventType
************************************
FUNCTION GetEventCategory
LPARAMETERS tcBuffer, tcUNCSourceName, tcSource, toReg
LOCAL lcRetVal, lnCategory, lcLookup, lnKey, lcCMF, lnKeyValue, lcCMFx,
lnRet, ;
hResource, lcBuffer, lnI
lcRetVal = ""
lnCategory = StrToLong(SUBSTR(tcBuffer, 29, 2))
IF lnCategory = 0
lcRetVal = "None"
ELSE
lcLookUp = "SYSTEM\CurrentControlSet\Services\EventLog\" +
ALLTRIM(tcUNCSourceName) + "\" + tcSource
RETURN lcRetVal
**************************************************
FUNCTION GetUserInfo
LPARAMETERS tcBuffer
LOCAL lnUserSIDLength, lnUserSIDOffset, lcRetVal, lcSID, lnI, lcName,
cbName, ;
lcReferencedDomainName, cbReferencedDomainName, peUse, lnRet
IF lnUserSIDLength > 0
lcSid = ""
FOR lnI = 1 TO lnUserSIDLength
lcSid = lcSid + SUBSTR(tcBuffer, lnUserSIDOffset + lnI, 1)
NEXT
lcName = SPACE(1)
cbName = LongToStr(1)
lcReferencedDomainName = SPACE(1)
cbReferencedDomainName = LongToStr(1)
peUse = SPACE(1)
lnRet = LookupAccountSid("", ;
@lcSid, ;
@lcName, ;
@cbName, ;
@lcReferencedDomainName, ;
@cbReferencedDomainName, ;
@peUse)
lnRet = GetLastError()
IF lnRet = ERROR_INSUFFICIENT_BUFFER
lcName = SPACE(StrToLong(cbName))
lcReferencedDomainName = SPACE(StrToLong(cbReferencedDomainName))
lnRet = LookupAccountSid("", ;
@lcSid, ;
@lcName, ;
@cbName, ;
@lcReferencedDomainName, ;
@cbReferencedDomainName, ;
@peUse)
IF lnRet = 1
lcRetVal = lcRetVal + lcName
ELSE
lcRetVal = lcRetVal + "N/A"
ENDIF
ELSE
lcRetVal = lcRetVal + "N/A"
ENDIF
ELSE
lcRetVal = lcRetVal + "N/A"
ENDIF
lcRetVal = "User: " + lcRetVal + CHR(13) + CHR(10)
RETURN lcRetVal
***********************************************
PROCEDURE GetDescription
LPARAMETERS tcBuffer, tcUNCSourceName, tcSource, toReg
LOCAL lcLookup, lnRet, lcEMF, lcEMFx, lnNumStrings, pStr, lcPtrs, lnI, ;
hResource, lcBuffer, lnEventNo, lcMessage, lcRetVal
lcLookUp = "SYSTEM\CurrentControlSet\Services\EventLog\" +
ALLTRIM(tcUNCSourceName) + "\" + tcSource
IF lnRet = 0
lcEMF = ""
lnRet = oReg.GetKeyValue("EventMessageFile", @lcEMF)
IF lnRet = 0
lnNumStrings = StrToLong(SUBSTR(tcBuffer, 27, 2))
pStr = StrToLong(SUBSTR(cBuffer, 37, 4)) + 1
lcMessage = ""
lcPtrs = ""
lcEMFx = SPACE(255)
lnRet = ExpandEnvironmentStrings(@lcEMF, @lcEMFx, 255)
hResource = LoadLibraryEx(ALLTRIM(lcEMFx), 0,
LOAD_LIBRARY_AS_DATAFILE)
IF hResource > 0
lcBuffer = SPACE(1000)
lnRet = FormatMessage(BITOR(FORMAT_MESSAGE_FROM_HMODULE,
FORMAT_MESSAGE_ARGUMENT_ARRAY, 60), ;
hResource, ;
StrToLong(SUBSTR(tcBuffer, 21, 2)), ;
0, ;
@lcBuffer, ;
10000, ;
@lcPtrs)
IF lnRet = 0
lnEventNo = StrToLong(SUBSTR(tcBuffer, 21, 2))
lnRet = GetLastError()
ELSE
lcRetVal = ALLTRIM(lcBuffer)
ENDIF
FreeLibrary(hResource)
ENDIF
******************
* From KB Article ID: Q181289
******************
FUNCTION LongToStr
* The following function converts a long integer to an ASCII
* character representation of the passed value in low-high format.
* Passed: 32-bit non-negative numeric value (lnLongval)
* Returns: ascii character representation of passed value in low-high
* format
LPARAMETERS tnLongval
LOCAL lnI, lcRetVal
lcRetVal = ""
FOR lnI = 24 TO 0 STEP -8
lcRetVal = CHR(INT(tnLongVal / (2 ^ lnI))) + lcRetVal
lnLongval = MOD(tnLongVal, (2 ^ lnI))
NEXT
RETURN lcRetVal
******************
FUNCTION StrToLong
* Convert a string in low-high format to a long integer.
* Passed: 4-byte character string (lcLongstr) in low-high ASCII format
* Returns: long integer value
LPARAMETERS tcLongStr
LOCAL lcLongStr, lnI, lnRetval
lcLongStr = tcLongStr
lnRetVal = 0
FOR lnI = 0 TO 24 STEP 8
lnRetVal = lnRetVal + (ASC(lcLongStr) * (2 ^ lnI))
lcLongStr = RIGHT(lcLongStr, LEN(lcLongStr) - 1)
NEXT
RETURN lnRetVal
You can see that the code that actually reads the log is quite small. Most of the code is for
formatting the output.
The following steps show you how to setup automatic maintenance of the log:
Figure 7. The Application Properties dialog is used to maintain the Event Log.
3. Use the Log size settings to maintain the size of the Event Log.
4. Click Clear Log to clear all entries from the log
5. Click OK to close the properties dialog.
You can also maintain the log programmatically. The following code shows how to backup the
log:
***************************************
lcUNCSourceName = "C:\"
lcBackupLog = "C:\Temp\BackupLog.Evt"
ERASE (lcBackupLog)
lnRet = CloseEventLog(hEventLog)
IF lnRet = 0
MESSAGEBOX("CloseEventLog Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
The following code shows how to clear the Windows Event Log:
***************************************
lcUNCSourceName = "C:\"
lcBackupLog = "C:\Temp\Backup.Evt"
lcNewBackup = NULL
hEventLog = OpenEventLog(NULL, lcUNCSourceName)
IF hEventLog = 0
MESSAGEBOX("OpenEventLog Error: " + ALLTRIM(STR(GetLastError())))
RETURN
ENDIF
IF !ISNULL(lcBackupLog)
ERASE (lcBackupLog)
ENDIF
lnRet = CloseEventLog(hEventLog)
IF lnRet = 0
MESSAGEBOX("CloseEventLog Error: " + ALLTRIM(STR(GetLastError())))
ENDIF
Summary
It may seem that using the Windows Event Log is very complex, but you could create a class that
will handle this for you and a generic message file, making the job easier. By using the Windows
Event Log, you will centralize event reporting, use a format common to many other applications,
and make event reporting easier for the end user.
Craig Berntson is a Microsoft Most Valuable Professional (MVP) for Visual FoxPro, a Microsoft Certified Solution
Developer, and President of the Salt Lake City Fox User Group. He wrote the book CrysDev: A Developer s
Guide to Integrating Crystal Reports , available from Hentzenwerke Publishing. He has also written for FoxTalk
and the Visual FoxPro User Group (VFUG) newsletter. He has spoken at Advisor DevCon, Essential Fox, the Great
Lakes Great Database Workshop, Southwest Fox, Microsoft DevDays and user groups around the country.
Currently, Craig is a Senior Software Engineer at 3M Health Information Systems in Salt Lake City. You can reach
him a craig@craigberntson.com or visit his website, www.craigberntson.com.
Cole Gleave is a Senior Software Engineer at 3M Health Information Systems in Salt Lake City. Previously he was
Vice-President of Technology at Convenient Automation, specializing in software for the retail industry. He has
worked with FoxPro since version 2.6. You can reach him at cgleave@yahoo.com.