diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php index f5a34ae3d6..3e7abe6a9d 100644 --- a/src/aphront/configuration/AphrontApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -1,487 +1,500 @@ request = $request; return $this; } final public function getRequest() { return $this->request; } final public function getConsole() { return $this->console; } final public function setConsole($console) { $this->console = $console; return $this; } final public function setHost($host) { $this->host = $host; return $this; } final public function getHost() { return $this->host; } final public function setPath($path) { $this->path = $path; return $this; } final public function getPath() { return $this->path; } public function willBuildRequest() {} /** * @phutil-external-symbol class PhabricatorStartup */ public static function runHTTPRequest(AphrontHTTPSink $sink) { $multimeter = MultimeterControl::newInstance(); $multimeter->setEventContext(''); $multimeter->setEventViewer(''); PhabricatorEnv::initializeWebEnvironment(); $multimeter->setSampleRate( PhabricatorEnv::getEnvConfig('debug.sample-rate')); $debug_time_limit = PhabricatorEnv::getEnvConfig('debug.time-limit'); if ($debug_time_limit) { PhabricatorStartup::setDebugTimeLimit($debug_time_limit); } // This is the earliest we can get away with this, we need env config first. PhabricatorAccessLog::init(); $access_log = PhabricatorAccessLog::getLog(); PhabricatorStartup::setGlobal('log.access', $access_log); $access_log->setData( array( 'R' => AphrontRequest::getHTTPHeader('Referer', '-'), 'r' => idx($_SERVER, 'REMOTE_ADDR', '-'), 'M' => idx($_SERVER, 'REQUEST_METHOD', '-'), )); DarkConsoleXHProfPluginAPI::hookProfiler(); DarkConsoleErrorLogPluginAPI::registerErrorHandler(); $response = PhabricatorSetupCheck::willProcessRequest(); if ($response) { PhabricatorStartup::endOutputCapture(); $sink->writeResponse($response); return; } $host = AphrontRequest::getHTTPHeader('Host'); $path = $_REQUEST['__path__']; switch ($host) { default: $config_key = 'aphront.default-application-configuration-class'; $application = PhabricatorEnv::newObjectFromConfig($config_key); break; } $application->setHost($host); $application->setPath($path); $application->willBuildRequest(); $request = $application->buildRequest(); // Build the server URI implied by the request headers. If an administrator // has not configured "phabricator.base-uri" yet, we'll use this to generate // links. $request_protocol = ($request->isHTTPS() ? 'https' : 'http'); $request_base_uri = "{$request_protocol}://{$host}/"; PhabricatorEnv::setRequestBaseURI($request_base_uri); $access_log->setData( array( 'U' => (string)$request->getRequestURI()->getPath(), )); $write_guard = new AphrontWriteGuard(array($request, 'validateCSRF')); $processing_exception = null; try { - $response = $application->processRequest($request, $access_log, $sink); + $response = $application->processRequest( + $request, + $access_log, + $sink, + $multimeter); $response_code = $response->getHTTPResponseCode(); } catch (Exception $ex) { $processing_exception = $ex; $response_code = 500; } $write_guard->dispose(); $access_log->setData( array( 'c' => $response_code, 'T' => PhabricatorStartup::getMicrosecondsSinceStart(), )); + $multimeter->newEvent( + MultimeterEvent::TYPE_REQUEST_TIME, + $multimeter->getEventContext(), + PhabricatorStartup::getMicrosecondsSinceStart()); + $access_log->write(); $multimeter->saveEvents(); DarkConsoleXHProfPluginAPI::saveProfilerSample($access_log); // Add points to the rate limits for this request. if (isset($_SERVER['REMOTE_ADDR'])) { $user_ip = $_SERVER['REMOTE_ADDR']; // The base score for a request allows users to make 30 requests per // minute. $score = (1000 / 30); // If the user was logged in, let them make more requests. if ($request->getUser() && $request->getUser()->getPHID()) { $score = $score / 5; } PhabricatorStartup::addRateLimitScore($user_ip, $score); } if ($processing_exception) { throw $processing_exception; } } public function processRequest( AphrontRequest $request, PhutilDeferredLog $access_log, - AphrontHTTPSink $sink) { + AphrontHTTPSink $sink, + MultimeterControl $multimeter) { $this->setRequest($request); list($controller, $uri_data) = $this->buildController(); + $controller_class = get_class($controller); $access_log->setData( array( - 'C' => get_class($controller), + 'C' => $controller_class, )); + $multimeter->setEventContext('web.'.$controller_class); $request->setURIMap($uri_data); $controller->setRequest($request); // If execution throws an exception and then trying to render that // exception throws another exception, we want to show the original // exception, as it is likely the root cause of the rendering exception. $original_exception = null; try { $response = $controller->willBeginExecution(); if ($request->getUser() && $request->getUser()->getPHID()) { $access_log->setData( array( 'u' => $request->getUser()->getUserName(), 'P' => $request->getUser()->getPHID(), )); + $multimeter->setEventViewer('user.'.$request->getUser()->getPHID()); } if (!$response) { $controller->willProcessRequest($uri_data); $response = $controller->handleRequest($request); } } catch (Exception $ex) { $original_exception = $ex; $response = $this->handleException($ex); } try { $response = $controller->didProcessRequest($response); $response = $this->willSendResponse($response, $controller); $response->setRequest($request); $unexpected_output = PhabricatorStartup::endOutputCapture(); if ($unexpected_output) { $unexpected_output = pht( "Unexpected output:\n\n%s", $unexpected_output); phlog($unexpected_output); if ($response instanceof AphrontWebpageResponse) { echo phutil_tag( 'div', array('style' => 'background: #eeddff;'. 'white-space: pre-wrap;'. 'z-index: 200000;'. 'position: relative;'. 'padding: 8px;'. 'font-family: monospace', ), $unexpected_output); } } $sink->writeResponse($response); } catch (Exception $ex) { if ($original_exception) { throw $original_exception; } throw $ex; } return $response; } /* -( URI Routing )-------------------------------------------------------- */ /** * Using builtin and application routes, build the appropriate * @{class:AphrontController} class for the request. To route a request, we * first test if the HTTP_HOST is configured as a valid Phabricator URI. If * it isn't, we do a special check to see if it's a custom domain for a blog * in the Phame application and if that fails we error. Otherwise, we test * against all application routes from installed * @{class:PhabricatorApplication}s. * * If we match a route, we construct the controller it points at, build it, * and return it. * * If we fail to match a route, but the current path is missing a trailing * "/", we try routing the same path with a trailing "/" and do a redirect * if that has a valid route. The idea is to canoncalize URIs for consistency, * but avoid breaking noncanonical URIs that we can easily salvage. * * NOTE: We only redirect on GET. On POST, we'd drop parameters and most * likely mutate the request implicitly, and a bad POST usually indicates a * programming error rather than a sloppy typist. * * If the failing path already has a trailing "/", or we can't route the * version with a "/", we call @{method:build404Controller}, which build a * fallback @{class:AphrontController}. * * @return pair Controller and dictionary of request * parameters. * @task routing */ final public function buildController() { $request = $this->getRequest(); // If we're configured to operate in cluster mode, reject requests which // were not received on a cluster interface. // // For example, a host may have an internal address like "170.0.0.1", and // also have a public address like "51.23.95.16". Assuming the cluster // is configured on a range like "170.0.0.0/16", we want to reject the // requests received on the public interface. // // Ideally, nodes in a cluster should only be listening on internal // interfaces, but they may be configured in such a way that they also // listen on external interfaces, since this is easy to forget about or // get wrong. As a broad security measure, reject requests received on any // interfaces which aren't on the whitelist. $cluster_addresses = PhabricatorEnv::getEnvConfig('cluster.addresses'); if ($cluster_addresses) { $server_addr = idx($_SERVER, 'SERVER_ADDR'); if (!$server_addr) { if (php_sapi_name() == 'cli') { // This is a command line script (probably something like a unit // test) so it's fine that we don't have SERVER_ADDR defined. } else { throw new AphrontUsageException( pht('No SERVER_ADDR'), pht( 'Phabricator is configured to operate in cluster mode, but '. 'SERVER_ADDR is not defined in the request context. Your '. 'webserver configuration needs to forward SERVER_ADDR to '. 'PHP so Phabricator can reject requests received on '. 'external interfaces.')); } } else { if (!PhabricatorEnv::isClusterAddress($server_addr)) { throw new AphrontUsageException( pht('External Interface'), pht( 'Phabricator is configured in cluster mode and the address '. 'this request was received on ("%s") is not whitelisted as '. 'a cluster address.', $server_addr)); } } } if (PhabricatorEnv::getEnvConfig('security.require-https')) { if (!$request->isHTTPS()) { $https_uri = $request->getRequestURI(); $https_uri->setDomain($request->getHost()); $https_uri->setProtocol('https'); // In this scenario, we'll be redirecting to HTTPS using an absolute // URI, so we need to permit an external redirect. return $this->buildRedirectController($https_uri, true); } } $path = $request->getPath(); $host = $request->getHost(); $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); $prod_uri = PhabricatorEnv::getEnvConfig('phabricator.production-uri'); $file_uri = PhabricatorEnv::getEnvConfig( 'security.alternate-file-domain'); $allowed_uris = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris'); $uris = array_merge( array( $base_uri, $prod_uri, ), $allowed_uris); $cdn_routes = array( '/res/', '/file/data/', '/file/xform/', '/phame/r/', ); $host_match = false; foreach ($uris as $uri) { if ($host === id(new PhutilURI($uri))->getDomain()) { $host_match = true; break; } } if (!$host_match) { if ($host === id(new PhutilURI($file_uri))->getDomain()) { foreach ($cdn_routes as $route) { if (strncmp($path, $route, strlen($route)) == 0) { $host_match = true; break; } } } } // NOTE: If the base URI isn't defined yet, don't activate alternate // domains. if ($base_uri && !$host_match) { try { $blog = id(new PhameBlogQuery()) ->setViewer(new PhabricatorUser()) ->withDomain($host) ->executeOne(); } catch (PhabricatorPolicyException $ex) { throw new Exception( 'This blog is not visible to logged out users, so it can not be '. 'visited from a custom domain.'); } if (!$blog) { if ($prod_uri && $prod_uri != $base_uri) { $prod_str = ' or '.$prod_uri; } else { $prod_str = ''; } throw new Exception( 'Specified domain '.$host.' is not configured for Phabricator '. 'requests. Please use '.$base_uri.$prod_str.' to visit this instance.' ); } // TODO: Make this more flexible and modular so any application can // do crazy stuff here if it wants. $path = '/phame/live/'.$blog->getID().'/'.$path; } list($controller, $uri_data) = $this->buildControllerForPath($path); if (!$controller) { if (!preg_match('@/$@', $path)) { // If we failed to match anything but don't have a trailing slash, try // to add a trailing slash and issue a redirect if that resolves. list($controller, $uri_data) = $this->buildControllerForPath($path.'/'); // NOTE: For POST, just 404 instead of redirecting, since the redirect // will be a GET without parameters. if ($controller && !$request->isHTTPPost()) { $slash_uri = $request->getRequestURI()->setPath($path.'/'); $external = strlen($request->getRequestURI()->getDomain()); return $this->buildRedirectController($slash_uri, $external); } } return $this->build404Controller(); } return array($controller, $uri_data); } /** * Map a specific path to the corresponding controller. For a description * of routing, see @{method:buildController}. * * @return pair Controller and dictionary of request * parameters. * @task routing */ final public function buildControllerForPath($path) { $maps = array(); $applications = PhabricatorApplication::getAllInstalledApplications(); foreach ($applications as $application) { $maps[] = array($application, $application->getRoutes()); } $current_application = null; $controller_class = null; foreach ($maps as $map_info) { list($application, $map) = $map_info; $mapper = new AphrontURIMapper($map); list($controller_class, $uri_data) = $mapper->mapPath($path); if ($controller_class) { if ($application) { $current_application = $application; } break; } } if (!$controller_class) { return array(null, null); } $request = $this->getRequest(); $controller = newv($controller_class, array()); if ($current_application) { $controller->setCurrentApplication($current_application); } return array($controller, $uri_data); } } diff --git a/src/applications/multimeter/controller/MultimeterSampleController.php b/src/applications/multimeter/controller/MultimeterSampleController.php index 1b60022eec..62a459f75b 100644 --- a/src/applications/multimeter/controller/MultimeterSampleController.php +++ b/src/applications/multimeter/controller/MultimeterSampleController.php @@ -1,284 +1,328 @@ getViewer(); $group_map = $this->getColumnMap(); $group = explode('.', $request->getStr('group')); $group = array_intersect($group, array_keys($group_map)); $group = array_fuse($group); if (empty($group['type'])) { $group['type'] = 'type'; } $now = PhabricatorTime::getNow(); $ago = ($now - phutil_units('24 hours in seconds')); $table = new MultimeterEvent(); $conn = $table->establishConnection('r'); $where = array(); $where[] = qsprintf( $conn, 'epoch >= %d AND epoch <= %d', $ago, $now); $with = array(); foreach ($group_map as $key => $column) { + + // Don't let non-admins filter by viewers, this feels a little too + // invasive of privacy. + if ($key == 'viewer') { + if (!$viewer->getIsAdmin()) { + continue; + } + } + $with[$key] = $request->getStrList($key); if ($with[$key]) { $where[] = qsprintf( $conn, '%T IN (%Ls)', $column, $with[$key]); } } $where = '('.implode(') AND (', $where).')'; $data = queryfx_all( $conn, 'SELECT *, count(*) N, SUM(sampleRate * resourceCost) as totalCost FROM %T WHERE %Q GROUP BY %Q ORDER BY totalCost DESC, MAX(id) DESC LIMIT 100', $table->getTableName(), $where, implode(', ', array_select_keys($group_map, $group))); $this->loadDimensions($data); + $phids = array(); + foreach ($data as $row) { + $viewer_name = $this->getViewerDimension($row['eventViewerID']) + ->getName(); + $viewer_phid = $this->getEventViewerPHID($viewer_name); + if ($viewer_phid) { + $phids[] = $viewer_phid; + } + } + $handles = $viewer->loadHandles($phids); $rows = array(); foreach ($data as $row) { if (isset($group['request'])) { $request_col = $row['requestKey']; if (!$with['request']) { $request_col = $this->renderSelectionLink( 'request', $row['requestKey'], $request_col); } } else { $request_col = $this->renderGroupingLink($group, 'request'); } if (isset($group['viewer'])) { - $viewer_col = $this->getViewerDimension($row['eventViewerID']) - ->getName(); - if (!$with['viewer']) { - $viewer_col = $this->renderSelectionLink( - 'viewer', - $row['eventViewerID'], - $viewer_col); + if ($viewer->getIsAdmin()) { + $viewer_col = $this->getViewerDimension($row['eventViewerID']) + ->getName(); + + $viewer_phid = $this->getEventViewerPHID($viewer_col); + if ($viewer_phid) { + $viewer_col = $handles[$viewer_phid]->getName(); + } + + if (!$with['viewer']) { + $viewer_col = $this->renderSelectionLink( + 'viewer', + $row['eventViewerID'], + $viewer_col); + } + } else { + $viewer_col = phutil_tag('em', array(), pht('(Masked)')); } } else { $viewer_col = $this->renderGroupingLink($group, 'viewer'); } if (isset($group['context'])) { $context_col = $this->getContextDimension($row['eventContextID']) ->getName(); if (!$with['context']) { $context_col = $this->renderSelectionLink( 'context', $row['eventContextID'], $context_col); } } else { $context_col = $this->renderGroupingLink($group, 'context'); } if (isset($group['host'])) { $host_col = $this->getHostDimension($row['eventHostID']) ->getName(); if (!$with['host']) { $host_col = $this->renderSelectionLink( 'host', $row['eventHostID'], $host_col); } } else { $host_col = $this->renderGroupingLink($group, 'host'); } if (isset($group['label'])) { $label_col = $this->getLabelDimension($row['eventLabelID']) ->getName(); if (!$with['label']) { $label_col = $this->renderSelectionLink( 'label', $row['eventLabelID'], $label_col); } } else { $label_col = $this->renderGroupingLink($group, 'label'); } if ($with['type']) { $type_col = MultimeterEvent::getEventTypeName($row['eventType']); } else { $type_col = $this->renderSelectionLink( 'type', $row['eventType'], MultimeterEvent::getEventTypeName($row['eventType'])); } $rows[] = array( ($row['N'] == 1) ? $row['id'] : pht('%s Events', new PhutilNumber($row['N'])), $request_col, $viewer_col, $context_col, $host_col, $type_col, $label_col, + MultimeterEvent::formatResourceCost( + $viewer, + $row['eventType'], + $row['totalCost'] / $row['N']), MultimeterEvent::formatResourceCost( $viewer, $row['eventType'], $row['totalCost']), - $row['sampleRate'], + ($row['N'] == 1) + ? $row['sampleRate'] + : '-', phabricator_datetime($row['epoch'], $viewer), ); } $table = id(new AphrontTableView($rows)) ->setHeaders( array( pht('ID'), pht('Request'), pht('Viewer'), pht('Context'), pht('Host'), pht('Type'), pht('Label'), + pht('Avg'), pht('Cost'), pht('Rate'), pht('Epoch'), )) ->setColumnClasses( array( 'n', null, null, null, null, null, 'wide', 'n', 'n', + 'n', null, )); $box = id(new PHUIObjectBoxView()) ->setHeaderText( pht( 'Samples (%s - %s)', phabricator_datetime($ago, $viewer), phabricator_datetime($now, $viewer))) ->appendChild($table); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb( pht('Samples'), $this->getGroupURI(array(), true)); $crumb_map = array( 'host' => pht('By Host'), 'context' => pht('By Context'), 'viewer' => pht('By Viewer'), 'request' => pht('By Request'), 'label' => pht('By Label'), ); $parts = array(); foreach ($group as $item) { if ($item == 'type') { continue; } $parts[$item] = $item; $crumbs->addTextCrumb( idx($crumb_map, $item, $item), $this->getGroupURI($parts, true)); } return $this->buildApplicationPage( array( $crumbs, $box, ), array( 'title' => pht('Samples'), )); } private function renderGroupingLink(array $group, $key) { $group[] = $key; $uri = $this->getGroupURI($group); return phutil_tag( 'a', array( 'href' => $uri, 'style' => 'font-weight: bold', ), pht('(All)')); } private function getGroupURI(array $group, $wipe = false) { unset($group['type']); $uri = clone $this->getRequest()->getRequestURI(); $group = implode('.', $group); if (!strlen($group)) { $group = null; } $uri->setQueryParam('group', $group); if ($wipe) { foreach ($this->getColumnMap() as $key => $column) { $uri->setQueryParam($key, null); } } return $uri; } private function renderSelectionLink($key, $value, $link_text) { $value = (array)$value; $uri = clone $this->getRequest()->getRequestURI(); $uri->setQueryParam($key, implode(',', $value)); return phutil_tag( 'a', array( 'href' => $uri, ), $link_text); } private function getColumnMap() { return array( 'type' => 'eventType', 'host' => 'eventHostID', 'context' => 'eventContextID', 'viewer' => 'eventViewerID', 'request' => 'requestKey', 'label' => 'eventLabelID', ); } + private function getEventViewerPHID($viewer_name) { + if (!strncmp($viewer_name, 'user.', 5)) { + return substr($viewer_name, 5); + } + return null; + } + } diff --git a/src/applications/multimeter/data/MultimeterControl.php b/src/applications/multimeter/data/MultimeterControl.php index a7838ca859..9d948609a6 100644 --- a/src/applications/multimeter/data/MultimeterControl.php +++ b/src/applications/multimeter/data/MultimeterControl.php @@ -1,203 +1,207 @@ sampleRate !== 0) && ($this->pauseDepth == 0); } public function setSampleRate($rate) { if ($rate && (mt_rand(1, $rate) == $rate)) { $sample_rate = $rate; } else { $sample_rate = 0; } $this->sampleRate = $sample_rate; return; } public function pauseMultimeter() { $this->pauseDepth++; return $this; } public function unpauseMultimeter() { if (!$this->pauseDepth) { throw new Exception(pht('Trying to unpause an active multimeter!')); } $this->pauseDepth--; return $this; } public function newEvent($type, $label, $cost) { if (!$this->isActive()) { return null; } $event = id(new MultimeterEvent()) ->setEventType($type) ->setEventLabel($label) ->setResourceCost($cost) ->setEpoch(PhabricatorTime::getNow()); $this->events[] = $event; return $event; } public function saveEvents() { if (!$this->isActive()) { return; } $events = $this->events; if (!$events) { return; } if ($this->sampleRate === null) { throw new Exception(pht('Call setSampleRate() before saving events!')); } // Don't sample any of this stuff. $this->pauseMultimeter(); $use_scope = AphrontWriteGuard::isGuardActive(); if ($use_scope) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); } else { AphrontWriteGuard::allowDangerousUnguardedWrites(true); } $caught = null; try { $this->writeEvents(); } catch (Exception $ex) { $caught = $ex; } if ($use_scope) { unset($unguarded); } else { AphrontWriteGuard::allowDangerousUnguardedWrites(false); } $this->unpauseMultimeter(); if ($caught) { throw $caught; } } private function writeEvents() { $events = $this->events; $random = Filesystem::readRandomBytes(32); $request_key = PhabricatorHash::digestForIndex($random); $host_id = $this->loadHostID(php_uname('n')); $context_id = $this->loadEventContextID($this->eventContext); $viewer_id = $this->loadEventViewerID($this->eventViewer); $label_map = $this->loadEventLabelIDs(mpull($events, 'getEventLabel')); foreach ($events as $event) { $event ->setRequestKey($request_key) ->setSampleRate($this->sampleRate) ->setEventHostID($host_id) ->setEventContextID($context_id) ->setEventViewerID($viewer_id) ->setEventLabelID($label_map[$event->getEventLabel()]) ->save(); } } public function setEventContext($event_context) { $this->eventContext = $event_context; return $this; } + public function getEventContext() { + return $this->eventContext; + } + public function setEventViewer($viewer) { $this->eventViewer = $viewer; return $this; } private function loadHostID($host) { $map = $this->loadDimensionMap(new MultimeterHost(), array($host)); return idx($map, $host); } private function loadEventViewerID($viewer) { $map = $this->loadDimensionMap(new MultimeterViewer(), array($viewer)); return idx($map, $viewer); } private function loadEventContextID($context) { $map = $this->loadDimensionMap(new MultimeterContext(), array($context)); return idx($map, $context); } private function loadEventLabelIDs(array $labels) { return $this->loadDimensionMap(new MultimeterLabel(), $labels); } private function loadDimensionMap(MultimeterDimension $table, array $names) { $hashes = array(); foreach ($names as $name) { $hashes[] = PhabricatorHash::digestForIndex($name); } $objects = $table->loadAllWhere('nameHash IN (%Ls)', $hashes); $map = mpull($objects, 'getID', 'getName'); $need = array(); foreach ($names as $name) { if (isset($map[$name])) { continue; } $need[] = $name; } foreach ($need as $name) { $object = id(clone $table) ->setName($name) ->save(); $map[$name] = $object->getID(); } return $map; } } diff --git a/src/applications/multimeter/storage/MultimeterEvent.php b/src/applications/multimeter/storage/MultimeterEvent.php index 1f7c27cbaa..23263a6e8d 100644 --- a/src/applications/multimeter/storage/MultimeterEvent.php +++ b/src/applications/multimeter/storage/MultimeterEvent.php @@ -1,71 +1,76 @@ eventLabel = $event_label; return $this; } public function getEventLabel() { return $this->eventLabel; } public static function getEventTypeName($type) { switch ($type) { case self::TYPE_STATIC_RESOURCE: return pht('Static Resource'); + case self::TYPE_REQUEST_TIME: + return pht('Web Request'); } return pht('Unknown ("%s")', $type); } public static function formatResourceCost( PhabricatorUser $viewer, $type, $cost) { switch ($type) { case self::TYPE_STATIC_RESOURCE: return pht('%s Req', new PhutilNumber($cost)); + case self::TYPE_REQUEST_TIME: + return pht('%s us', new PhutilNumber($cost)); } return pht('%s Unit(s)', new PhutilNumber($cost)); } protected function getConfiguration() { return array( self::CONFIG_TIMESTAMPS => false, self::CONFIG_COLUMN_SCHEMA => array( 'eventType' => 'uint32', 'resourceCost' => 'sint64', 'sampleRate' => 'uint32', 'requestKey' => 'bytes12', ), self::CONFIG_KEY_SCHEMA => array( 'key_request' => array( 'columns' => array('requestKey'), ), 'key_type' => array( 'columns' => array('eventType', 'epoch'), ), ), ) + parent::getConfiguration(); } }