<?PHP
#
#   FILE:  MetricsRecorder.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2012-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis
#

/**
* Plugin for recording usage and system metrics data.
*/
class MetricsRecorder extends Plugin {

    # ---- STANDARD PLUGIN INTERFACE -----------------------------------------

    /**
    * Set the plugin attributes.  At minimum this method MUST set $this->Name
    * and $this->Version.  This is called when the plugin is initially loaded.
    */
    function Register()
    {
        $this->Name = "Metrics Recorder";
        $this->Version = "1.2.4";
        $this->Description = "CWIS plugin for recording usage and web metrics data.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
                "CWISCore" => "2.2.3",
                "BotDetector" => "1.0.0");
        $this->EnabledByDefault = FALSE;
    }

    /**
    * Initialize the plugin.  This is called after all plugins have been loaded
    * but before any methods for this plugin (other than Register() or Initialize())
    * have been called.
    * @return NULL if initialization was successful, otherwise a string containing
    *       an error message indicating why initialization failed.
    */
    function Initialize()
    {
        $this->DB = new Database();
        return NULL;
    }

    /**
    * Perform any work needed when the plugin is first installed (for example,
    * creating database tables).
    * @return NULL if installation succeeded, otherwise a string containing
    *       an error message indicating why installation failed.
    */
    function Install()
    {
        $Queries = array(
                "CREATE TABLE IF NOT EXISTS MetricsRecorder_SampleData (
                    SampleDate      TIMESTAMP,
                    SampleType      SMALLINT NOT NULL,
                    SampleValue     INT,
                    INDEX           (SampleDate,SampleType));",
                "CREATE TABLE IF NOT EXISTS MetricsRecorder_EventData (
                    EventId         INT NOT NULL AUTO_INCREMENT,
                    EventDate       TIMESTAMP,
                    EventType       SMALLINT NOT NULL,
                    UserId          INT,
                    IPAddress       INT UNSIGNED,
                    DataOne         TEXT,
                    DataTwo         TEXT,
                    INDEX           Index_I (EventId),
                    INDEX           Index_TD (EventType,EventDate),
                    INDEX           Index_TUD (EventType,UserId,EventDate),
                    INDEX           Index_T (EventType),
                    INDEX           Index_TO (EventType,DataOne(8)),
                    INDEX           Index_TOW (EventType,DataOne(8),DataTwo(8)) );",
                "CREATE TABLE IF NOT EXISTS MetricsRecorder_EventAnnotations (
                    EventId         INT NOT NULL,
                    AnnotatorId     SMALLINT,
                    Annotation      TEXT,
                    INDEX           (EventId));",
                "CREATE TABLE IF NOT EXISTS MetricsRecorder_Annotators (
                    AnnotatorId     SMALLINT NOT NULL AUTO_INCREMENT,
                    AnnotatorName   TEXT,
                    INDEX           (AnnotatorId));",
                "CREATE TABLE IF NOT EXISTS MetricsRecorder_EventTypeIds (
                    OwnerName       TEXT,
                    TypeName        TEXT,
                    TypeId          SMALLINT NOT NULL,
                    INDEX           (TypeId));",
                );
        $DB = new Database();
        foreach ($Queries as $Query)
        {
            $Result = $DB->Query($Query);
            if ($Result === FALSE) {  break;  }
        }
        return ($Result === FALSE) ? "Database setup failed." : NULL;
    }

    /**
    * Perform any work needed when the plugin is uninstalled.
    * @return NULL if uninstall succeeded, otherwise a string containing
    *       an error message indicating why uninstall failed.
    */
    function Uninstall()
    {
        # delete all our database tables
        $this->DB->Query("DROP TABLE MetricsRecorder_SampleData");
        $this->DB->Query("DROP TABLE MetricsRecorder_EventData");
        $this->DB->Query("DROP TABLE MetricsRecorder_EventAnnotations");
        $this->DB->Query("DROP TABLE MetricsRecorder_Annotators");
        $this->DB->Query("DROP TABLE MetricsRecorder_EventTypeIds");

        # report success to caller
        return NULL;
    }

    /**
    * Perform any work needed when the plugin is upgraded to a new version
    * (for example, adding fields to database tables).
    * @param string $PreviousVersion The version number of this plugin that was
    *       previously installed.
    * @return NULL if upgrade succeeded, otherwise a string containing
    *       an error message indicating why upgrade failed.
    */
    function Upgrade($PreviousVersion)
    {
        # if previously-installed version is earlier than 1.1.4
        if (version_compare($PreviousVersion, "1.1.4", "<"))
        {
            # replace old Type/User index with Type/User/Date index for event data
            $DB = new Database();
            $DB->SetQueryErrorsToIgnore(array(
                    '/DROP\s+INDEX\s+[^\s]+\s+\([^)]+\)/i'
                            => '/Can\'t\s+DROP\s+[^\s]+\s+check\s+that/i'));
            $DB->Query("DROP INDEX EventType_2 ON MetricsRecorder_EventData");
            $DB->Query("CREATE INDEX Index_TUD ON MetricsRecorder_EventData"
                    ." (EventType,UserId,EventDate)");
        }

        # if previously-installed version is earlier than 1.2.0
        if (version_compare($PreviousVersion, "1.2.0", "<"))
        {
            # add table for tracking custom event types
            $DB = new Database();
            $DB->Query("CREATE TABLE IF NOT EXISTS MetricsRecorder_EventTypeIds (
                    OwnerName       TEXT,
                    TypeName        TEXT,
                    TypeId          SMALLINT NOT NULL,
                    INDEX           (TypeId));");
        }

        # if previously-installed version is earlier than 1.2.1
        if (version_compare($PreviousVersion, "1.2.1", "<"))
        {
            # set the errors that can be safely ignord
            $DB = new Database();
            $DB->SetQueryErrorsToIgnore(array(
                '/^RENAME\s+TABLE/i' => '/already\s+exists/i'));

            # fix the custom event type ID mapping table name
            $DB->Query("RENAME TABLE MetricsRecorder_EventTypes
                    TO MetricsRecorder_EventTypeIds");

            # remove full record views and resource URL clicks for resources
            # that don't use the default schema
            $DB->Query("DELETE ED FROM MetricsRecorder_EventData ED
                    LEFT JOIN Resources R ON ED.DataOne = R.ResourceId
                    WHERE (EventType = '".intval(self::ET_FULLRECORDVIEW)."'
                    OR EventType = '".intval(self::ET_URLFIELDCLICK)."')
                    AND R.SchemaId != '".intval(MetadataSchema::SCHEMAID_DEFAULT)."'");
        }

        # if previously-installed version is earlier than 1.2.3
        if (version_compare($PreviousVersion, "1.2.3", "<"))
        {
            # set the errors that can be safely ignord
            $DB = new Database();
            $DB->SetQueryErrorsToIgnore(array(
                    "/ALTER TABLE [a-z]+ ADD INDEX/i" => "/Duplicate key name/i"));

            # add indexes to speed view and click count retrieval
            $DB->Query("ALTER TABLE MetricsRecorder_EventData"
                    ." ADD INDEX Index_TO (EventType,DataOne(8))");
            $DB->Query("ALTER TABLE MetricsRecorder_EventData"
                    ." ADD INDEX Index_TOW (EventType,DataOne(8),DataTwo(8))");
        }
    }

    /**
    * Declare events defined by this plugin.  This is used when a plugin defines
    * new events that it signals or responds to.  Names of these events should
    * begin with the plugin base name, followed by "_EVENT_" and the event name
    * in all caps (for example "MyPlugin_EVENT_MY_EVENT").
    * @return Array with event names for the index and event types for the values.
    */
    function DeclareEvents()
    {
        return array(
                "MetricsRecorder_EVENT_RECORD_EVENT"
                        => ApplicationFramework::EVENTTYPE_NAMED,
                );
    }

    /**
    * Hook the events into the application framework.
    * @return Returns an array of events to be hooked into the application
    *      framework.
    */
    function HookEvents()
    {
        return array(
                "EVENT_DAILY" => "RecordDailySampleData",
                "EVENT_USER_LOGIN" => "RecordUserLogin",
                "EVENT_USER_ADDED" => "RecordNewUserAdded",
                "EVENT_USER_VERIFIED" => "RecordNewUserVerified",
                "EVENT_SEARCH_COMPLETE" => "RecordSearch",
                "EVENT_OAIPMH_REQUEST" => "RecordOAIRequest",
                "EVENT_FULL_RECORD_VIEW" => "RecordFullRecordView",
                "EVENT_URL_FIELD_CLICK" => "RecordUrlFieldClick",
                );

    }


    # ---- HOOKED METHODS ----------------------------------------------------

    /**
    * Record periodically-sampled data.
    * @param LastRunAt Timestamp when method was last called.
    */
    function RecordDailySampleData($LastRunAt)
    {
        # record total number of registered users
        $UserFactory = new CWUserFactory();
        $this->RecordSample(self::ST_REGUSERCOUNT, $UserFactory->GetUserCount());

        # record number of privileged users
        $this->RecordSample(self::ST_PRIVUSERCOUNT,
                count($UserFactory->GetUsersWithPrivileges(
                        PRIV_SYSADMIN, PRIV_NEWSADMIN, PRIV_RESOURCEADMIN,
                        PRIV_FORUMADMIN, PRIV_CLASSADMIN, PRIV_NAMEADMIN,
                        PRIV_USERADMIN, PRIV_COLLECTIONADMIN)));

        # record total number of resources
        $ResourceFactory = new ResourceFactory();
        $this->RecordSample(self::ST_RESOURCECOUNT, $ResourceFactory->GetItemCount());

        # record number of resources that have been rated
        $this->RecordSample(self::ST_RATEDRESOURCECOUNT,
                $ResourceFactory->GetRatedResourceCount());

        # record number of users who have rated resources
        $this->RecordSample(self::ST_RRESOURCEUSERCOUNT,
                $ResourceFactory->GetRatedResourceUserCount());

        # record number of searches currently saved
        $SavedSearchFactory = new SavedSearchFactory();
        $this->RecordSample(self::ST_SAVEDSEARCHCOUNT,
                $SavedSearchFactory->GetItemCount());

        # record number of users who currently have searches saved
        $this->RecordSample(self::ST_SSEARCHUSERCOUNT,
                $SavedSearchFactory->GetSearchUserCount());

        # record number of users that have logged in within the last day
        $this->RecordSample(self::ST_DAILYLOGINCOUNT,
                $UserFactory->GetUserCount(
                        "LastLoginDate > '".addslashes($LastRunAt)."'"));

        # record number of new accounts created and verified within the last day
        $this->RecordSample(self::ST_DAILYNEWACCTS,
                $UserFactory->GetUserCount(
                        "CreationDate > '".addslashes($LastRunAt)
                        ."' AND RegistrationConfirmed > 0"));
    }

    /**
    * Record user logging in.
    * @param int $UserId ID of user that logged in.
    * @param string $Password Password entered by user.
    */
    function RecordUserLogin($UserId, $Password)
    {
        $this->RecordEventData(self::ET_USERLOGIN, NULL, NULL, $UserId);
    }

    /**
    * Record new user being added.
    * @param int $UserId ID of user that was added.
    * @param string $Password Password entered by user.
    */
    function RecordNewUserAdded($UserId, $Password)
    {
        $this->RecordEventData(self::ET_NEWUSERADDED, NULL, NULL, $UserId);
    }

    /**
    * Record new user account being verified.
    * @param int $UserId ID of user that was verified.
    */
    function RecordNewUserVerified($UserId)
    {
        $this->RecordEventData(self::ET_NEWUSERVERIFIED, NULL, NULL, $UserId);
    }

    /**
    * Record search being performed.
    * @param bool $AdvancedSearch TRUE if fielded search, otherwise FALSE.
    * @param array $SearchParameters Array containing search parameters.
    * @param array $SearchResults Array containing search results, with
    *       search scores for the values and resource IDs for the indexes.
    */
    function RecordSearch($AdvancedSearch, $SearchParameters, $SearchResults)
    {
        $this->RecordEventData(
                ($AdvancedSearch ? self::ET_ADVANCEDSEARCH : self::ET_SEARCH),
                $SearchParameters, count($SearchResults));
    }

    /**
    * Record OAI-PMH harvest request.
    * @param string $RequesterIP IP address of requester.
    * @param string $QueryString GET string for query.
    */
    function RecordOAIRequest($RequesterIP, $QueryString)
    {
        $this->RecordEventData(
                self::ET_OAIREQUEST, $RequesterIP, $QueryString, NULL, 0);
    }

    /**
    * Record user viewing full resource record page.
    * @param int $ResourceId ID of resource.
    */
    function RecordFullRecordView($ResourceId)
    {
        $Resource = new Resource($ResourceId);

        # ignore resources if they don't use the default schema
        if ($Resource->SchemaId() != MetadataSchema::SCHEMAID_DEFAULT)
        {
            return;
        }

        # if referring URL is available
        $SearchGroups = NULL;
        if (isset($_SERVER["HTTP_REFERER"]))
        {
            # if GET parameters are part of referring URL
            $Pieces = parse_url($_SERVER["HTTP_REFERER"]);
            if (isset($Pieces["query"]))
            {
                # if search parameters look to be available from GET parameters
                $Args = ParseQueryString($Pieces["query"]);
                if (isset($Args["P"]) && ($Args["P"] == "AdvancedSearch"))
                {
                    # extract search parameters for recording with view
                    $SearchGroups =
                            SavedSearch::TranslateSearchGroupsToUrlParameters(
                            SavedSearch::TranslateUrlParametersToSearchGroups(
                            $Pieces["query"]));
                }
            }
        }

        # record full record view
        $this->RecordEventData(
                self::ET_FULLRECORDVIEW, $ResourceId, $SearchGroups);
    }

    /**
    * Record user clicking on a Url metadata field value.
    * @param int $ResourceId ID of resource.
    * @param int $FieldId ID of metadata field.
    */
    function RecordUrlFieldClick($ResourceId, $FieldId)
    {
        $Resource = new Resource($ResourceId);

        # ignore resources if they don't use the default schema
        if ($Resource->SchemaId() != MetadataSchema::SCHEMAID_DEFAULT)
        {
            return;
        }

        $this->RecordEventData(self::ET_URLFIELDCLICK, $ResourceId, $FieldId);
    }


    # ---- CALLABLE METHODS (Administrative) ---------------------------------

    /**
    * Register custom event type as valid.  Recording or retrieving data with
    * an owner and/or type that has not been registered will cause an exception.
    * @param string $Owner Name of event data owner (caller-defined string).
    * @param string $Type Type of event (caller-defined string).
    */
    function RegisterEventType($Owner, $Type)
    {
        # add type to list
        $this->CustomEventTypes[] = $Owner.$Type;
    }

    /**
    * Remove events recorded with a specified IP address.  The starting and/or
    * ending date can be specified in any format parseable by strtotime().
    * @param string $IPAddress Address to remove for, in dotted-quad format.
    * @param string $StartDate Starting date/time of period (inclusive) for
    *       which to remove events.  (OPTIONAL, defaults to NULL, which imposes
    *       no starting date)
    * @param string $EndDate Ending date/time of period (inclusive) for
    *       which to remove events.  (OPTIONAL, defaults to NULL, which imposes
    *       no ending date)
    */
    function RemoveEventsForIPAddress($IPAddress, $StartDate = NULL, $EndDate = NULL)
    {
        $Query = "DELETE FROM MetricsRecorder_EventData"
                ." WHERE IPAddress = INET_ATON('"
                .addslashes($IPAddress)."')";
        if ($StartDate !== NULL)
        {
            $Query .= " AND EventDate <= '"
                    .date(DATE_SQL, strtotime($StartDate))."'";
        }
        if ($EndDate !== NULL)
        {
            $Query .= " AND EventDate >= '"
                    .date(DATE_SQL, strtotime($EndDate))."'";
        }
        $this->DB->Query($Query);
    }


    # ---- CALLABLE METHODS (Storage) ----------------------------------------

    /**
    * Record event data.  Any of the optional arguments can be supplied
    * as NULL to be skipped.  Event data is not recorded if it is a duplicate
    * of previously-recorded data that falls within the dedupe time, or if
    * it was reported as being triggered by a bot via the BotDetector plugin.
    * @param string $Owner Name of event data owner (caller-defined string).
    * @param string $Type Type of event (caller-defined string).
    * @param mixed $DataOne First piece of event data.  (OPTIONAL)
    * @param mixed $DataTwo Second piece of event data.  (OPTIONAL)
    * @param int $UserId ID of user associated with event.  (OPTIONAL)
    * @param int $DedupeTime Time in seconds that must elapse between events
    *       for them not to be considered as duplicates and ignored.  (OPTIONAL,
    *       defaults to 10)
    * @param bool $CheckForBot Determines whether to check if the event was
    *       triggered by a bot and, if so, to ignore the event. (OPTIONAL,
    *       defaults to TRUE)
    * @return bool TRUE if event data was recorded (not considered a duplicate
    *       or triggered by a bot), otherwise FALSE.
    * @throws Exception if event type was not found.
    * @see MetricsRecorder::GetEventData()
    */
    function RecordEvent($Owner, $Type,
            $DataOne = NULL, $DataTwo = NULL, $UserId = NULL, $DedupeTime = 10,
            $CheckForBot = TRUE)
    {
        # map owner and type names to numerical event type
        $EventTypeId = $this->GetEventTypeId($Owner, $Type);

        # store data and report success of storage attempt to caller
        return $this->RecordEventData(
            $EventTypeId, $DataOne, $DataTwo, $UserId, $DedupeTime, $CheckForBot);
    }


    # ---- CALLABLE METHODS (Retrieval) --------------------------------------

    /**
    * Retrieve event data.
    * @param mixed $Owner Name of event data owner (caller-defined string
    *       or "MetricsRecorder" if a native MetricsRecorder event).  May be an
    *       array if multiple event types specified and searching for multiple
    *       types of events with different owners.
    * @param mixed $Type Type(s) of event (a caller-defined string or a constant
    *       for native MetricsRecorder events).  May be an array to search
    *       for multiple types of events at once.
    * @param string $StartDate Beginning of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param string $EndDate End of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param int $UserId ID of user associated with event.  (OPTIONAL, defaults to NULL)
    * @param mixed $DataOne First generic data value associated with the event.
    *       This may also be an array of values.  (OPTIONAL, defaults to NULL)
    * @param mixed $DataTwo Second generic data value associated with the event.
    *       This may also be an array of values.  (OPTIONAL, defaults to NULL)
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL, defaults to empty array)
    * @param int $Offset Zero-based offset into results array.  (OPTIONAL, defaults to 0)
    * @param int $Count Number of results to return (NULL to retrieve all).
    *       (OPTIONAL, defaults to NULL)
    * @return array Array with unique event IDs for the index and associative arrays
    *       for the value, with the indexes "EventDate", "UserId", "IPAddress",
    *       "DataOne", and "DataTwo".
    * @throws Exception if no matching event owner found for event type.
    * @throws Exception if event type was not found.
    * @throws Exception if no event types specified.
    * @see MetricsRecorder::RecordEvent()
    */
    function GetEventData($Owner, $Type,
            $StartDate = NULL, $EndDate = NULL, $UserId = NULL,
            $DataOne = NULL, $DataTwo = NULL, $PrivsToExclude = array(),
            $Offset = 0, $Count = NULL)
    {
        # map owner and type names to numerical event types
        $Types = is_array($Type) ? $Type : array($Type);
        foreach ($Types as $Index => $EventType)
        {
            if (is_array($Owner))
            {
                if (array_key_exists($Index, $Owner))
                {
                    $EventOwner = $Owner[$Index];
                }
                else
                {
                    throw new Exception("No corresponding owner found"
                            ." for event type ".$EventType);
                }
            }
            else
            {
                $EventOwner = $Owner;
            }
            $EventTypeIds[$Index] = $this->GetEventTypeId($EventOwner, $EventType);
        }
        if (!isset($EventTypeIds))
        {
            throw new Exception("No event types specified.");
        }

        # begin building database query
        $Query = "SELECT * FROM MetricsRecorder_EventData";

        # add claus(es) for event type(s) to query
        $Separator = " WHERE (";
        foreach ($EventTypeIds as $Id)
        {
            $Query .= $Separator."EventType = ".$Id;
            $Separator = " OR ";
        }
        $Query .= ")";

        # add clauses for user and date range to query
        $Query .= (($UserId === NULL) ? "" : " AND UserId = ".intval($User->Id()))
                .(($StartDate === NULL) ? ""
                        : " AND EventDate >= '".addslashes($StartDate)."'")
                .(($EndDate === NULL) ? ""
                        : " AND EventDate <= '".addslashes($EndDate)."'");

        # add clauses for data values
        foreach (array("DataOne" => $DataOne, "DataTwo" => $DataTwo) as $Name => $Data)
        {
            if ($Data !== NULL)
            {
                if (is_array($Data) AND count($Data))
                {
                    $Query .= " AND ".$Name." IN (";
                    $Separator = "";
                    foreach ($Data as $Value)
                    {
                        $Query .= $Separator."'".addslashes($Value)."'";
                        $Separator = ",";
                    }
                    $Query .= ")";
                }
                else
                {
                    $Query .= " AND ".$Name." = '".addslashes($Data)."'";
                }
            }
        }

        # add clause for user exclusion if specified
        if (count($PrivsToExclude))
        {
            $Query .= " AND (UserId IS NULL OR UserId NOT IN ("
                    .User::GetSqlQueryForUsersWithPriv($PrivsToExclude)."))";
        }

        # add sorting and (if specified) return value limits to query
        $Query .= " ORDER BY EventDate ASC";
        if ($Count)
        {
            $Query .= " LIMIT ".($Offset ? intval($Offset)."," : "").intval($Count);
        }
        elseif ($Offset)
        {
            $Query .= " LIMIT ".intval($Offset).",".PHP_INT_MAX;
        }

        # retrieve data
        $Data = array();
        $this->DB->Query($Query);
        while ($Row = $this->DB->FetchRow())
        {
            $EventId = $Row["EventId"];
            unset($Row["EventId"]);
            unset($Row["EventType"]);
            $Row["IPAddress"] = long2ip($Row["IPAddress"]);
            $Data[$EventId] = $Row;
        }

        # return array with retrieved data to caller
        return $Data;
    }

    /**
    * Retrieve count of events matching specified parameters.
    * @param mixed $Owner Name of event data owner (caller-defined string
    *       or "MetricsRecorder" if a native MetricsRecorder event).  May be an
    *       array if multiple event types specified and searching for multiple
    *       types of events with different owners.
    * @param mixed $Type Type(s) of event (a caller-defined string or a constant
    *       for native MetricsRecorder events).  May be an array to search
    *       for multiple types of events at once.
    * @param mixed $Period Either "HOUR", "DAY", "WEEK", "MONTH", "YEAR", or
    *       NULL to total up all available events.  The "WEEK" period begins on
    *       Sunday.  (OPTIONAL, defaults to NULL)
    * @param string $StartDate Beginning of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param string $EndDate End of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param int $UserId ID of user associated with event.  (OPTIONAL, defaults to NULL)
    * @param mixed $DataOne First generic data value associated with the event.
    *       This may also be an array of values.  (OPTIONAL, defaults to NULL)
    * @param mixed $DataTwo Second generic data value associated with the event.
    *       This may also be an array of values.  (OPTIONAL, defaults to NULL)
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL, defaults to empty array)
    * @return array Count of events found that match the specified parameters,
    *       with unix timestamps for the index if a period was specified.
    * @throws Exception if no matching event owner found for event type.
    * @throws Exception if event type was not found.
    * @throws Exception if no event types specified.
    * @throws Exception if $Period value is invalid.
    * @see MetricsRecorder::RecordEvent()
    */
    function GetEventCounts($Owner, $Type, $Period = NULL,
            $StartDate = NULL, $EndDate = NULL, $UserId = NULL,
            $DataOne = NULL, $DataTwo = NULL, $PrivsToExclude = array())
    {
        # map owner and type names to numerical event types
        $Types = is_array($Type) ? $Type : array($Type);
        foreach ($Types as $Index => $EventType)
        {
            if (is_array($Owner))
            {
                if (array_key_exists($Index, $Owner))
                {
                    $EventOwner = $Owner[$Index];
                }
                else
                {
                    throw new Exception("No corresponding owner found"
                            ." for event type ".$EventType);
                }
            }
            else
            {
                $EventOwner = $Owner;
            }
            $EventTypeIds[$Index] = $this->GetEventTypeId($EventOwner, $EventType);
        }
        if (!isset($EventTypeIds))
        {
            throw new Exception("No event types specified.");
        }

        # begin constructing database query (it's a modest start)
        $Query = "SELECT";

        # if a period was specified
        if ($Period !== NULL)
        {
            # normalize period string
            $Period = strtoupper($Period);
            $Period = ($Period == "DAILY") ? "DAY"
                    : preg_replace("/LY\$/", "", $Period);

            # add extra calculated value to query to retrieve and group values by
            $PeriodFilters = array(
                    "HOUR" => "%Y-%m-%d %k:00:00",
                    "DAY" => "%Y-%m-%d 00:00:00",
                    "WEEK" => "%X-%V",
                    "MONTH" => "%Y-%m-01 00:00:00",
                    "YEAR" => "%Y-01-01 00:00:00",
                    );
            if (!array_key_exists($Period, $PeriodFilters))
            {
                throw new Exception("Invalid period specified (".$Period.").");
            }
            $Query .= " DATE_FORMAT(EventDate, '"
                    .$PeriodFilters[$Period]."') as Period,";
        }

        # continue building query
        $Query .= " COUNT(*) AS EventCount FROM MetricsRecorder_EventData";

        # add claus(es) for event type(s) to query
        $Separator = " WHERE (";
        foreach ($EventTypeIds as $Id)
        {
            $Query .= $Separator."EventType = ".$Id;
            $Separator = " OR ";
        }
        $Query .= ")";

        # add clauses for user and date range to query
        $Query .= (($UserId === NULL) ? "" : " AND UserId = ".intval($User->Id()))
                .(($StartDate === NULL) ? ""
                        : " AND EventDate >= '".addslashes($StartDate)."'")
                .(($EndDate === NULL) ? ""
                        : " AND EventDate <= '".addslashes($EndDate)."'");

        # add clauses for data values
        foreach (array("DataOne" => $DataOne, "DataTwo" => $DataTwo) as $Name => $Data)
        {
            if ($Data !== NULL)
            {
                if (is_array($Data) AND count($Data))
                {
                    $Query .= " AND ".$Name." IN (";
                    $Separator = "";
                    foreach ($Data as $Value)
                    {
                        $Query .= $Separator."'".addslashes($Value)."'";
                        $Separator = ",";
                    }
                    $Query .= ")";
                }
                else
                {
                    $Query .= " AND ".$Name." = '".addslashes($Data)."'";
                }
            }
        }

        # add clause to exclude users if supplied
        if (count($PrivsToExclude))
        {
            $Query .= " AND (UserId IS NULL OR UserId NOT IN ("
                    .User::GetSqlQueryForUsersWithPriv($PrivsToExclude)."))";
        }

        # add grouping to query if period was specified
        if ($Period !== NULL)
        {
            $Query .= " GROUP BY Period";
        }

        # run query against database
        $this->DB->Query($Query);

        # if no period was specified
        if ($Period === NULL)
        {
            # retrieve total count
            $Data = $this->DB->FetchColumn("EventCount");
        }
        else
        {
            # retrieve counts for each period
            $Data = $this->DB->FetchColumn("EventCount", "Period");

            # for each count
            $NewData = array();
            foreach ($Data as $Date => $Count)
            {
                # if period is a week
                if ($Period == "WEEK")
                {
                    # split returned date into week number and year values
                    list($Year, $Week) = explode("-", $Date);

                    # adjust values if week is at end of year
                    if ($Week < 53)
                    {
                        $Week = $Week + 1;
                    }
                    else
                    {
                        $Week = 1;
                        $Year = $Year + 1;
                    }

                    # convert week number and year into Unix timestamp
                    $Timestamp = strtotime(sprintf("%04dW%02d0", $Year, $Week));
                }
                else
                {
                    # convert date into Unix timestamp
                    $Timestamp = strtotime($Date);
                }

                # save count with new Unix timestamp index value
                $NewData[$Timestamp] = $Count;
            }
            $Data = $NewData;
        }

        # return count(s) to caller
        return $Data;
    }

    /**
    * Retrieve sample data recorded by MetricsRecorder.
    * @param mixed $Type Type of sample (ST_ constant).
    * @param string $StartDate Beginning of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param string $EndDate End of date of range to search (inclusive),
    *       in SQL date format.  (OPTIONAL, defaults to NULL)
    * @param int $Offset Zero-based offset into results array.  (OPTIONAL, defaults to 0)
    * @param int $Count Number of results to return (NULL to retrieve all).
    *       (OPTIONAL, defaults to NULL)
    * @return array Array with sample timestamps (in SQL date format) for the
    *       index and sample values for the values.
    * @see MetricsRecorder::RecordDailySampleData()
    */
    function GetSampleData($Type, $StartDate = NULL, $EndDate = NULL,
            $Offset = 0, $Count = NULL)
    {
        # construct database query to retrieve data
        $Query = "SELECT * FROM MetricsRecorder_SampleData"
                ." WHERE SampleType = ".intval($Type)
                .(($StartDate === NULL) ? ""
                        : " AND SampleDate >= '".addslashes($StartDate)."'")
                .(($EndDate === NULL) ? ""
                        : " AND SampleDate <= '".addslashes($EndDate)."'")
                ." ORDER BY SampleDate ASC";
        if ($Count)
        {
            $Query .= " LIMIT ".($Offset ? intval($Offset)."," : "").intval($Count);
        }
        elseif ($Offset)
        {
            $Query .= " LIMIT ".intval($Offset).",".PHP_INT_MAX;
        }

        # retrieve data and return to caller
        $this->DB->Query($Query);
        return $this->DB->FetchColumn("SampleValue", "SampleDate");
    }

    /**
    * Retrieve list of full record view events.
    * @param User User (object) to retrieve full record views for.  Specify NULL for
    *       all users.  (OPTIONAL - defaults to NULL)
    * @param Count Number of views to return.  (OPTIONAL - defaults to 10)
    * @param Offset Offset into list of views to begin.  (OPTIONAL - defaults to 0)
    * @param NoDupes Whether to eliminate duplicate records with record IDs,
    *       returning only the newest.  (OPTIONAL - defaults to TRUE)
    * @return Array with event ID as index and associative array of view attributes
    *       as value ("Date", "ResourceId", "IPAddress", "QueryString", "UserId")
    */
    function GetFullRecordViews($User = NULL, $Count = 10, $Offset = 0, $NoDupes = TRUE)
    {
        # do while we need more views and there are records left in DB
        $Views = array();
        do
        {
            # retrieve block of view records from database
            $this->DB->Query("SELECT * FROM MetricsRecorder_EventData"
                    ." WHERE EventType = ".self::ET_FULLRECORDVIEW
                    .(($User !== NULL) ? " AND UserId = ".intval($User->Id()) : " ")
                    ." ORDER BY EventDate DESC"
                    ." LIMIT ".intval($Offset).",".$Count);
            $Offset += $Count;

            # while we need more views and records are left in the current block
            while ((count($Views) < $Count) && ($Row = $this->DB->FetchRow()))
            {
                # if caller did not care about dupes or record is not a dupe
                if (!$NoDupes || (!isset($SeenIDs[$Row["DataOne"]])))
                {
                    # add record to views
                    if ($NoDupes) {  $SeenIDs[$Row["DataOne"]] = TRUE;  }
                    $Views[$Row["EventId"]] = array(
                            "Date" => $Row["EventDate"],
                            "ResourceId" => $Row["DataOne"],
                            "IPAddress" => $Row["IPAddress"],
                            "QueryString" => $Row["DataTwo"],
                            "UserId" => $Row["UserId"],
                            );
                }
            }
        }
        while ((count($Views) < $Count) && $this->DB->NumRowsSelected());

        # return views to caller
        return $Views;
    }

    /**
    * Get count of full record views for specified resource.
    * @param int $ResourceId ID of resource.
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL, defaults to empty array)
    * @return int Record view count.
    */
    function GetFullRecordViewCount($ResourceId, $PrivsToExclude = array())
    {
        $Counts = $this->GetEventCounts("MetricsRecorder",
                self::ET_FULLRECORDVIEW, NULL, NULL, NULL, NULL,
                $ResourceId, NULL, $PrivsToExclude);
        return array_shift($Counts);
    }

    /**
    * Get count of URL clicks for specified resource and field.
    * @param int $ResourceId ID of resource.
    * @param int $FieldId ID of metadata field.
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL, defaults to empty array)
    * @return int Record view count.
    */
    function GetUrlFieldClickCount($ResourceId, $FieldId, $PrivsToExclude = array())
    {
        $Counts = $this->GetEventCounts("MetricsRecorder",
                self::ET_URLFIELDCLICK, NULL, NULL, NULL, NULL,
                $ResourceId, $FieldId, $PrivsToExclude);
        return array_shift($Counts);
    }

    /**
    * Retrieve count of clicks on specified URL field for each resource.  Date
    * parameters may be in any format parseable by strtotime().
    * @param int $FieldId ID of URL metadata field.
    * @param string $StartDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no lower bound to range.
    * @param string $EndDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no upper bound to range.
    * @param int $Offset Zero-based offset into results array.  (OPTIONAL,
    *       defaults to 0)
    * @param int $Count Number of results to return, or 0 to retrieve all.
    *       (OPTIONAL, defaults to 0)
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL)
    * @return aray Associative array of count data, with "Counts" containing
    *       array of full record view counts, indexed by resource IDs and
    *       sorted in descending order of view counts, "Total" containing the
    *       sum of all those counts, and "StartDate" and "EndDate" containing
    *       dates of first and last recorded views.
    */
    function GetUrlFieldClickCounts($FieldId, $StartDate = NULL, $EndDate = NULL,
            $Offset = 0, $Count = 0, $PrivsToExclude = array())
    {
        $Field = new MetadataField($FieldId);
        $AddedClause = "ED.DataTwo = ".intval($FieldId)
                ." AND (ED.DataOne = R.ResourceId AND R.SchemaId = "
                        .intval($Field->SchemaId()).")";
        $AddedTable = "Resources AS R";
        return $this->GetClickCounts(self::ET_URLFIELDCLICK, $StartDate, $EndDate,
                $Offset, $Count, $PrivsToExclude, $AddedClause, $AddedTable);
    }

    /**
    * Retrieve count of full record views for each resource.
    * @param int Metadata schema ID for resource records.  (OPTIONAL, defaults
    *       to SCHEMAID_DEFAULT)
    * @param string $StartDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no lower bound to range.
    * @param string $EndDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no upper bound to range.
    * @param int $Offset Zero-based offset into results array.  (OPTIONAL,
    *       defaults to 0)
    * @param int $Count Number of results to return, or 0 to retrieve all.
    *       (OPTIONAL, defaults to 0)
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL)
    * @return aray Associative array of count data, with "Counts" containing
    *       array of full record view counts, indexed by resource IDs and
    *       sorted in descending order of view counts, "Total" containing the
    *       sum of all those counts, and "StartDate" and "EndDate" containing
    *       dates of first and last recorded views.
    */
    function GetFullRecordViewCounts($SchemaId = MetadataSchema::SCHEMAID_DEFAULT,
            $StartDate = NULL, $EndDate = NULL,
            $Offset = 0, $Count = 0, $PrivsToExclude = array())
    {
        $AddedClause = "(ED.DataOne = R.ResourceId AND R.SchemaId = "
                        .intval($SchemaId).")";
        $AddedTable = "Resources AS R";
        return $this->GetClickCounts(self::ET_FULLRECORDVIEW, $StartDate, $EndDate,
                $Offset, $Count, $PrivsToExclude, $AddedClause, $AddedTable);
    }

    /** @name Sample data types */ /*@{*/
    const ST_REGUSERCOUNT = 1;       /**< Number of registered users */
    const ST_PRIVUSERCOUNT = 2;      /**< Number of privileged users */
    const ST_RESOURCECOUNT = 3;      /**< Number of resources  */
    const ST_RATEDRESOURCECOUNT = 4; /**< Number of rated resources  */
    const ST_RRESOURCEUSERCOUNT = 5; /**< Number of users who rated resources  */
    const ST_SAVEDSEARCHCOUNT = 6;   /**< Number of saved searches */
    const ST_SSEARCHUSERCOUNT = 7;   /**< Number of users with saved searches */
    const ST_DAILYLOGINCOUNT = 8;    /**< Number of logins in last day */
    const ST_DAILYNEWACCTS = 9;      /**< Number new accounts in last day */
    /*@}*/

    /** @name Event data types */ /*@{*/
    const ET_NONE = 0;                /**< no event (do not record) */
    const ET_USERLOGIN =  1;          /**< User logged in */
    const ET_NEWUSERADDED =  2;       /**< User signed up for new account */
    const ET_NEWUSERVERIFIED =  3;    /**< User verified new account */
    const ET_SEARCH =  4;             /**< Keyword search performed */
    const ET_ADVANCEDSEARCH =  5;     /**< Fielded search performed */
    const ET_OAIREQUEST =  6;         /**< OAI-PMH harvest request */
    const ET_FULLRECORDVIEW =  7;     /**< Full record page viewed */
    const ET_URLFIELDCLICK =  8;      /**< URL field clicked */
    /* (recording not yet implemented for following events) */
    const ET_RSSREQUEST = 9;          /**< RSS feed request */
    /*@}*/


    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $DB;
    private $CustomEventTypes = array();

    /**
    * Utility method to record sample data to database.
    * @param int $SampleType Type of sample.
    * @param int $SampleData Data for sample.
    */
    private function RecordSample($SampleType, $SampleValue)
    {
        $this->DB->Query("INSERT INTO MetricsRecorder_SampleData SET"
                ." SampleType = ".intval($SampleType).", "
                ." SampleValue = ".intval($SampleValue));
    }

    /**
    * Record event data.  Any of the optional arguments can be supplied
    * as NULL to be skipped.  Event data is not recorded if it is a duplicate
    * of previously-recorded data that falls within the dedupe time, or if
    * it was reported as being triggered by a bot via the BotDetector plugin.
    * @param int $EventTypeId Type of event.
    * @param mixed $DataOne First piece of event data.  (OPTIONAL)
    * @param mixed $DataTwo Second piece of event data.  (OPTIONAL)
    * @param int $UserId ID of user associated with event.  (OPTIONAL)
    * @param int $DedupeTime Time in seconds that must elapse between events
    *       for them not to be considered as duplicates and ignored.  (OPTIONAL,
    *       defaults to 10)
    * @param bool $CheckForBot Determines whether to check if the event was
    *       triggered by a bot and, if so, to ignore the event. (OPTIONAL,
    *       defaults to TRUE)
    * @return bool TRUE if event data was recorded (not considered a duplicate
    *       or triggered by a bot), otherwise FALSE.
    */
    private function RecordEventData($EventTypeId, $DataOne = NULL, $DataTwo = NULL,
            $UserId = NULL, $DedupeTime = 10, $CheckForBot = TRUE)
    {
        # if we should check if the event was triggered by a bot
        if ($CheckForBot)
        {
            # exit if event appears to be triggered by a bot
            $SignalResult = $GLOBALS["AF"]->SignalEvent(
                    "BotDetector_EVENT_CHECK_FOR_BOT");
            if ($SignalResult === TRUE) {  return FALSE;  }
        }

        # use ID of currently-logged-in user if none supplied
        if ($UserId === NULL)
        {
            if ($GLOBALS["G_User"]->Id()) {  $UserId = $GLOBALS["G_User"]->Id();  }
        }

        # if deduplication time specified
        if ($DedupeTime > 0)
        {
            $Query = "SELECT COUNT(*) AS RowCount FROM MetricsRecorder_EventData"
                    ." WHERE EventType = ".intval($EventTypeId)
                    ." AND EventDate >=  FROM_UNIXTIME('".(time() - $DedupeTime)."')";
            if ($DataOne !== NULL)
            {
                $Query .= " AND DataOne = '".addslashes($DataOne)."'";
            }
            if ($DataTwo !== NULL)
            {
                $Query .= " AND DataTwo = '".addslashes($DataTwo)."'";
            }
            if ($UserId !== NULL)
            {
                $Query .= " AND UserId = ".intval($GLOBALS["G_User"]->Id());
            }
            if (isset($_SERVER["REMOTE_ADDR"]) && ($_SERVER["REMOTE_ADDR"] != "::1"))
            {
                $Query .= " AND IPAddress = INET_ATON('"
                        .addslashes($_SERVER["REMOTE_ADDR"])."')";
            }
            $RowCount = $this->DB->Query($Query, "RowCount");
            if ($RowCount > 0) {  return FALSE;  }
        }

        # build query and save event info to database
        $Query = "INSERT INTO MetricsRecorder_EventData SET"
                ." EventType = ".intval($EventTypeId);
        if ($DataOne !== NULL)
        {
            $Query .= ", DataOne = '".addslashes($DataOne)."'";
        }
        if ($DataTwo !== NULL)
        {
            $Query .= ", DataTwo = '".addslashes($DataTwo)."'";
        }
        if ($UserId !== NULL)
        {
            $Query .= ", UserId = ".intval($GLOBALS["G_User"]->Id());
        }
        if (isset($_SERVER["REMOTE_ADDR"]) && ($_SERVER["REMOTE_ADDR"] != "::1"))
        {
            $Query .= ", IPAddress = INET_ATON('"
                    .addslashes($_SERVER["REMOTE_ADDR"])."')";
        }
        $this->DB->Query($Query);

        # retrieve ID of recorded event (for possible use in annotations)
        $EventId = $this->DB->LastInsertId();

        # signal to give other code a chance add event annotations
        $SignalResults = $GLOBALS["AF"]->SignalEvent(
                "MetricsRecorder_EVENT_RECORD_EVENT",
                array(
                        "EventType" => $EventTypeId,
                        "DataOne" => $DataOne,
                        "DataTwo" => $DataTwo,
                        "UserId" => $UserId,
                ));

        # if annotation data was returned by signal
        if (count($SignalResults))
        {
            # for each annotation
            foreach ($SignalResults as $Annotator => $Annotation)
            {
                # if annotation supplied
                if ($Annotation !== NULL)
                {
                    # look up ID for annotator
                    $AnnotatorId = $this->DB->Query("SELECT AnnotatorId"
                            ." FROM MetricsRecorder_Annotators"
                            ." WHERE AnnotatorName = '".addslashes($Annotator)."'",
                            "AnnotatorId");

                    # if no annotator ID found
                    if (!$this->DB->NumRowsSelected())
                    {
                        # add ID for annotator
                        $this->DB->Query("INSERT INTO MetricsRecorder_Annotators"
                                ." SET AnnotatorName = '".addslashes($Annotator)."'");
                        $AnnotatorId = $this->DB->LastInsertId();
                    }

                    # save annotation to database
                    $this->DB->Query("INSERT INTO MetricsRecorder_EventAnnotations"
                            ." (EventId, AnnotatorId, Annotation) VALUES ("
                            .$EventId.", ".$AnnotatorId.", "
                            ."'".addslashes($Annotation)."')");
                }
            }
        }

        # report to caller that event was stored
        return TRUE;
    }

    /**
    * Retrieve count data for click events (URL clicks, full record page
    * views, etc).
    * @param int $ClickType Event type.
    * @param string $StartDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no lower bound to range.
    * @param string $EndDate Beginning of date range (in any format
    *       parseable by strtotime()), or NULL for no upper bound to range.
    * @param int $Offset Beginning offset into count data results.
    * @param int $Count Number of count data results to retrieve.  (Pass
    *       in NULL to retrieve all results.  A count must be specified
    *       if an offset greater than zero is specified.)
    * @param array $PrivsToExclude Users with these privileges will be
    *       excluded from the results.  (OPTIONAL, defaults to empty array)
    * @param string $AddedClause Additional SQL conditional clause (without
    *       leading AND).  (OPTIONAL)
    * @param string $AddedTable Additional table(s) to include in query,
    *       usually in conjunction with $AddedClause.  (OPTIONAL)
    * @return aray Associative array of count data, with "Counts" containing
    *       array of full record view counts, indexed by resource IDs and
    *       sorted in descending order of view counts, "Total" containing the
    *       sum of all those counts, and "StartDate" and "EndDate" containing
    *       dates of first and last recorded views.
    */
    private function GetClickCounts($ClickType, $StartDate, $EndDate,
            $Offset, $Count, $PrivsToExclude,
            $AddedClause = NULL, $AddedTable = NULL)
    {
        # build query from supplied parameters
        $DBQuery = "SELECT ED.DataOne, COUNT(ED.DataOne) AS Clicks"
                ." FROM MetricsRecorder_EventData AS ED";
        if ($AddedTable) {  $DBQuery .= ", ".$AddedTable;  }
        $QueryConditional = " WHERE ED.EventType = ".$ClickType;
        if ($StartDate)
        {
            $StartDate = date("Y-m-d H:i:s", strtotime($StartDate));
            $QueryConditional .= " AND ED.EventDate >= '".addslashes($StartDate)."'";
        }
        if ($EndDate)
        {
            $EndDate = date("Y-m-d H:i:s", strtotime($EndDate));
            $QueryConditional .= " AND ED.EventDate <= '".addslashes($EndDate)."'";
        }
        if (count($PrivsToExclude))
        {
            $QueryConditional .= " AND (ED.UserId IS NULL OR ED.UserId NOT IN ("
                    .User::GetSqlQueryForUsersWithPriv($PrivsToExclude)."))";
        }
        if ($AddedClause) {  $QueryConditional .= " AND ".$AddedClause;  }
        $DBQuery .= $QueryConditional." GROUP BY ED.DataOne ORDER BY Clicks DESC";
        if ($Count)
        {
            $DBQuery .= " LIMIT ";
            if ($Offset) {  $DBQuery .= intval($Offset).", ";  }
            $DBQuery .= intval($Count);
        }

        # retrieve click counts from database
        $this->DB->Query($DBQuery);
        $Data["Counts"] = $this->DB->FetchColumn("Clicks", "DataOne");

        # sum up counts
        $Data["Total"] = 0;
        foreach ($Data["Counts"] as $ResourceId => $Count)
        {
            $Data["Total"] += $Count;
        }

        # retrieve earliest click date
        $DBQuery = "SELECT ED.EventDate FROM MetricsRecorder_EventData AS ED"
                .($AddedTable ? ", ".$AddedTable : "")
                .$QueryConditional." ORDER BY ED.EventDate ASC LIMIT 1";
        $Data["StartDate"] = $this->DB->Query($DBQuery, "EventDate");

        # retrieve latest click date
        $DBQuery = "SELECT ED.EventDate FROM MetricsRecorder_EventData AS ED"
                .($AddedTable ? ", ".$AddedTable : "")
                .$QueryConditional." ORDER BY ED.EventDate DESC LIMIT 1";
        $Data["EndDate"] = $this->DB->Query($DBQuery, "EventDate");

        # return click data to caller
        return $Data;
    }

    /**
    * Map custom event owner and type names to numerical event type.
    * @param string $OwnerName Name of event owner.
    * @param mixed $TypeName Name or (for native events) ID of event type.
    * @return int Numerical event type.
    * @throws Exception if event type was not found.
    */
    private function GetEventTypeId($OwnerName, $TypeName)
    {
        # if event is native
        if ($OwnerName == "MetricsRecorder")
        {
            # return ID unchanged
            return $TypeName;
        }

        # error out if event was not registered
        if (!in_array($OwnerName.$TypeName, $this->CustomEventTypes))
        {
            throw new Exception("Unknown event type ("
                    .$OwnerName." - ".$TypeName.")");
        }

        # load custom event type data (if not already loaded)
        static $TypeIds;
        if (!isset($TypeIds))
        {
            $TypeIds = array();
            $this->DB->Query("SELECT * FROM MetricsRecorder_EventTypeIds");
            while ($Row = $this->DB->FetchRow())
            {
                $TypeIds[$Row["OwnerName"].$Row["TypeName"]] = $Row["TypeId"];
            }
        }

        # if event type is not already defined
        if (!isset($TypeIds[$OwnerName.$TypeName]))
        {
            # find next available event type ID
            $HighestTypeId = count($TypeIds) ? max($TypeIds) : 1000;
            $TypeId = $HighestTypeId + 1;

            # add ID to local cache
            $TypeIds[$OwnerName.$TypeName] = $TypeId;

            # add event type to custom event type data in database
            $this->DB->Query("INSERT INTO MetricsRecorder_EventTypeIds"
                    ." (OwnerName, TypeName, TypeId) VALUES ("
                    ."'".addslashes($OwnerName)."',"
                    ."'".addslashes($TypeName)."',"
                    .$TypeId.")");
        }

        # return numerical event type to caller
        return $TypeIds[$OwnerName.$TypeName];
    }

}
