<?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.2";
        $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));",
                "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", "<"))
        {
            $DB = new Database();

            # set the errors that can be safely ignord
            $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)."'");
        }
    }

    /**
    * 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 (Storage) ----------------------------------------

    /**
    * Record custom 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.
    * @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
        $EventType = $this->GetCustomEventType($Owner, $Type);

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


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

    /**
    * Retrieve custom event data.
    * @param string $Owner Name of event data owner (caller-defined string).
    * @param string $Type Type of event (caller-defined string).
    * @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.
    *       (OPTIONAL, defaults to NULL)
    * @param mixed $DataTwo Second generic data value associated with the event.
    *       (OPTIONAL, defaults to NULL)
    * @param string $UserExclSubQuery Array of user privileges or SQL subquery for
    *       user IDs to exclude from count. (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 unique event IDs for the index and associative arrays
    *       for the value, with the indexes "EventDate", "UserId", "IPAddress",
    *       "DataOne", and "DataTwo".
    * @see MetricsRecorder::RecordEvent()
    */
    function GetEventData($Owner, $Type,
            $StartDate = NULL, $EndDate = NULL, $UserId = NULL,
            $DataOne = NULL, $DataTwo = NULL, $UserExclSubQuery = NULL,
            $Offset = 0, $Count = NULL)
    {
        # map owner and type names to numerical event type
        $EventType = $this->GetCustomEventType($Owner, $Type);

        # convert exclusion privileges (if provided) to exclusion subquery
        if (is_array($UserExclSubQuery))
        {
            if (count($UserExclSubQuery))
            {
                $UserExclSubQuery =
                        User::GetSqlQueryForUsersWithPriv($UserExclSubQuery);
            }
            else
            {
                $UserExclSubQuery = NULL;
            }
        }

        # construct database query to retrieve data
        if ($Count === NULL) {  $Count = PHP_INT_MAX;  }
        $Query = "SELECT * FROM MetricsRecorder_EventData"
                ." WHERE EventType = ".intval($EventType)
                .(($UserId === NULL) ? "" : " AND UserId = ".intval($User->Id()))
                .(($StartDate === NULL) ? ""
                        : " AND EventDate >= '".addslashes($StartDate)."'")
                .(($EndDate === NULL) ? ""
                        : " AND EventDate <= '".addslashes($EndDate)."'")
                .(($DataOne === NULL) ? "" : " AND DataOne = '".addslashes($DataOne)."'")
                .(($DataTwo === NULL) ? "" : " AND DataTwo = '".addslashes($DataTwo)."'")
                .((!$UserExclSubQuery) ? "" : " AND (UserId IS NULL"
                        ." OR UserId NOT IN (".$UserExclSubQuery."))")
                ." ORDER BY EventDate ASC"
                ." LIMIT ".intval($Offset).",".$Count;

        # 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 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 ResourceId ID of resource.
    * @param ExcludePrivs Array of privileges that will exclude users from count.
    *       (OPTIONAL - defaults to NULL)
    * @return Record view count.
    */
    function GetFullRecordViewCount($ResourceId, $ExcludePrivs = NULL)
    {
        if ($ExcludePrivs && count($ExcludePrivs))
        {
            $SubQuery = User::GetSqlQueryForUsersWithPriv($ExcludePrivs);
        }
        $ViewCount = $this->DB->Query("SELECT COUNT(*) AS ViewCount"
                ." FROM MetricsRecorder_EventData"
                ." WHERE EventType = ".self::ET_FULLRECORDVIEW
                ." AND DataOne = '".addslashes($ResourceId)."'"
                .(isset($SubQuery) ? " AND (UserId IS NULL OR UserId NOT IN ("
                        .$SubQuery."))" : ""),
                "ViewCount");
        return $ViewCount;
    }

    /**
    * Get count of URL clicks for specified resource and field.
    * @param ResourceId ID of resource.
    * @param FieldId ID of metadata field.
    * @param ExcludePrivs Array of privileges that will exclude users from count.
    *       (OPTIONAL - defaults to NULL)
    * @return Record view count.
    */
    function GetUrlFieldClickCount($ResourceId, $FieldId, $ExcludePrivs = NULL)
    {
        if ($ExcludePrivs && count($ExcludePrivs))
        {
            $SubQuery = User::GetSqlQueryForUsersWithPriv($ExcludePrivs);
        }
        $ClickCount = $this->DB->Query("SELECT COUNT(*) AS ClickCount"
                ." FROM MetricsRecorder_EventData"
                ." WHERE EventType = ".self::ET_URLFIELDCLICK
                ." AND DataOne = '".addslashes($ResourceId)."'"
                ." AND DataTwo = '".addslashes($FieldId)."'"
                .(isset($SubQuery) ? " AND (UserId IS NULL OR UserId NOT IN ("
                        .$SubQuery."))" : ""),
                "ClickCount");
        return $ClickCount;
    }

    /**
    * Retrieve count of clicks on specified URL field for each resource.
    * @param int $FieldId ID of URL metadata field.
    * @param string $StartDate Beginning date of range to search, in SQL date
    *       format.  (OPTIONAL, defaults to NULL)
    * @param string $EndDate Ending date of range to search, 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 (0 to retrieve all).
    *       (OPTIONAL, defaults to 0)
    * @param string $ResourceSubQuery SQL subquery to limit set of resource IDs.
    *       (OPTIONAL)
    * @return Associative array of count data, with "Counts" element containing
    *       array of URL click counts, indexed by resource IDs and sorted in
    *       descending order of click counts, "StartDate" and "EndDate" elements
    *       containing dates of first and last recorded clicks.
    */
    function GetUrlFieldClickCounts($FieldId, $StartDate = NULL, $EndDate = NULL,
            $Offset = 0, $Count = 0, $ResourceSubQuery = NULL, $UserExclSubQuery = NULL)
    {
        return $this->GetClickCounts(self::ET_URLFIELDCLICK, $StartDate, $EndDate,
                $Offset, $Count, $FieldId, $ResourceSubQuery, $UserExclSubQuery);
    }

    /**
    * Retrieve count of full record views for each resource.
    * @param StartDate Beginning date of range to search, in SQL date
    *       format.  (OPTIONAL, defaults to NULL)
    * @param EndDate Ending date of range to search, in SQL date
    *       format.  (OPTIONAL, defaults to NULL)
    * @param Offset Zero-based offset into results array.  (OPTIONAL, defaults to 0)
    * @param Count Number of results to return (0 to retrieve all).
    *       (OPTIONAL, defaults to 0)
    * @param ResourceSubQuery SQL subquery to limit set of resource IDs.
    *       (OPTIONAL, defaults to NULL))
    * @param UserExclSubQuery SQL subquery for user IDs to exclude from count. (OPTIONAL)
    * @return Associative array of count data, with "Counts" element containing
    *       array of full record view counts, indexed by resource IDs and sorted in
    *       descending order of view counts, "StartDate" and "EndDate" elements
    *       containing dates of first and last recorded views.
    */
    function GetFullRecordViewCounts($StartDate = NULL, $EndDate = NULL, $Offset = 0,
            $Count = 0, $ResourceSubQuery = NULL, $UserExclSubQuery = NULL)
    {
        return $this->GetClickCounts(self::ET_FULLRECORDVIEW, $StartDate, $EndDate,
                $Offset, $Count, NULL, $ResourceSubQuery, $UserExclSubQuery);
    }

    /** @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;

    /**
    * 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 $EventType 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($EventType, $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($EventType)
                    ." 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($EventType);
        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("MetricsRecorder_EventData");

        # signal to give other code a chance add event annotations
        $SignalResults = $GLOBALS["AF"]->SignalEvent(
                "MetricsRecorder_EVENT_RECORD_EVENT",
                array(
                        "EventType" => $EventType,
                        "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(
                                "MetricsRecorder_Annotators");
                    }

                    # 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 SQL-friendly
    *       date format.  Pass in NULL for no lower bound to range.
    * @param string $EndDate End of date range, in SQL-friendly
    *       date format.  Pass in 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 int $FieldId Metadata field ID (if retrieving data for an
    *       event type associated with a specific field).  If no field is
    *       associated with the event type, pass in NULL.
    * @param string $ResourceSubQuery SQL query to limit the results
    *       returned.  The query should return IDs for resources to be
    *       included in results.
    * @param string $UserExclSubQuery SQL query to exclude results
    *       associated with specific users.  The query should return
    *       IDs of users to exclude from results.
    */
    private function GetClickCounts($ClickType, $StartDate, $EndDate,
            $Offset, $Count, $FieldId, $ResourceSubQuery, $UserExclSubQuery)
    {
        # build query from supplied parameters
        $DBQuery = "SELECT DataOne, COUNT(DataOne) AS Clicks"
                ." FROM MetricsRecorder_EventData";
        $QueryConditional = " WHERE EventType = ".$ClickType;
        if ($StartDate)
        {
            $QueryConditional .= " AND EventDate >= '".addslashes($StartDate)."'";
        }
        if ($EndDate)
        {
            $QueryConditional .= " AND EventDate <= '".addslashes($EndDate)."'";
        }
        if ($FieldId)
        {
            $QueryConditional .= " AND DataTwo = '".addslashes($FieldId)."'";
        }
        if ($ResourceSubQuery)
        {
            $QueryConditional .= " AND DataOne IN (".$ResourceSubQuery.")";
        }
        if ($UserExclSubQuery)
        {
            $QueryConditional .= " AND (UserId IS NULL OR UserId NOT IN ("
                    .$UserExclSubQuery."))";
        }
        $DBQuery .= $QueryConditional." GROUP BY 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);

        # for each count returned
        $Counts = array();
        while ($Row = $this->DB->FetchRow())
        {
            # add count to data to be returned
            $Counts[$Row["DataOne"]] = $Row["Clicks"];
        }
        $Data["Counts"] = $Counts;

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

        # retrieve latest click date
        $DBQuery = "SELECT EventDate FROM MetricsRecorder_EventData"
                .$QueryConditional." ORDER BY 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 string $TypeName Name of event type.
    * @return int Numerical event type.
    */
    private function GetCustomEventType($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 custom event type value
            $HighestTypeId = count($TypeIds) ? max($TypeIds) : 1000;
            $TypeId = $HighestTypeId + 1;

            # add event type to custom event type data locally
            $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];
    }

}
