> */ private array $events = array(); /** * @var array */ private array $rollups = array(); /** * Record a sanitized click event. * * @param array $event Sanitized event payload. */ public function record( array $event ): void { $this->events[] = $event; foreach ( self::rollupKeys( $event ) as $key ) { $this->rollups[ $key ] = ( $this->rollups[ $key ] ?? 0 ) + 1; } } /** * Return recent click events. * * @param int $limit Maximum rows. * * @return list> */ public function recentEvents( int $limit = 500 ): array { if ( $limit <= 0 ) { return array(); } return array_slice( array_reverse( $this->events ), 0, $limit ); } /** * Count recent events matching a client bucket. * * @param string $userAgentHash User-agent hash. * @param string $refererHost Referer host. * @param DateTimeImmutable $since Lower bound. */ public function countRecentMatching( string $userAgentHash, string $refererHost, DateTimeImmutable $since ): int { $since = $since->setTimezone( new DateTimeZone( 'UTC' ) ); $count = 0; foreach ( $this->events as $event ) { $createdAt = self::createdAt( $event ); $metadata = isset( $event['metadata'] ) && is_array( $event['metadata'] ) ? $event['metadata'] : array(); if ( $createdAt === null || $createdAt < $since ) { continue; } if ( self::stringField( $metadata, 'user_agent_hash' ) === $userAgentHash && self::stringField( $metadata, 'referer_host' ) === $refererHost ) { ++$count; } } return $count; } /** * Delete click events older than cutoff. * * @param DateTimeImmutable $cutoff Cutoff instant. */ public function deleteOlderThan( DateTimeImmutable $cutoff ): int { $cutoff = $cutoff->setTimezone( new DateTimeZone( 'UTC' ) ); $kept = array(); $deleted = 0; foreach ( $this->events as $event ) { $createdAt = self::createdAt( $event ); if ( $createdAt !== null && $createdAt < $cutoff ) { ++$deleted; continue; } $kept[] = $event; } $this->events = $kept; $this->rollups = array(); foreach ( $this->events as $event ) { foreach ( self::rollupKeys( $event ) as $key ) { $this->rollups[ $key ] = ( $this->rollups[ $key ] ?? 0 ) + 1; } } return $deleted; } /** * @return list> */ public function events(): array { return $this->events; } /** * @return array */ public function rollups(): array { return $this->rollups; } /** * Read a string field. * * @param array $event Event. * @param string $field Field name. */ private static function stringField( array $event, string $field ): string { $value = $event[ $field ] ?? null; return is_string( $value ) ? $value : ''; } /** * Parse event creation timestamp. * * @param array $event Event. */ private static function createdAt( array $event ): ?DateTimeImmutable { $value = $event['created_at'] ?? null; if ( ! is_string( $value ) || trim( $value ) === '' ) { return null; } try { return ( new DateTimeImmutable( $value ) )->setTimezone( new DateTimeZone( 'UTC' ) ); } catch ( \Throwable $unused ) { unset( $unused ); return null; } } /** * @param array $event Event. * * @return list */ private static function rollupKeys( array $event ): array { $keys = array(); foreach ( array( 'provider', 'button_key', 'product_id', 'post_id', 'variant' ) as $field ) { if ( isset( $event[ $field ] ) && ( is_string( $event[ $field ] ) || is_int( $event[ $field ] ) ) ) { $keys[] = $field . ':' . (string) $event[ $field ]; } } return $keys; } }