sanitizer = new AO_Log_Sanitizer(); $this->storage = new AO_Log_Storage(); } /** * Returns the shared AO_Logger instance, creating it on first call. * * @return self */ public static function instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Returns a clone of this logger scoped to the given channel name. * * @param string $channel Channel identifier; sanitized before use. * @return self */ public function channel( string $channel ): self { $logger = clone $this; $logger->scoped_channel = $this->sanitize_identifier( $channel, 'core', 100, true ); return $logger; } /** * Writes a structured log record. * * @param array $context Log context. */ public function record( string $level, string $channel, string $event, string $message, array $context = [] ): bool { $level = in_array( strtolower( $level ), [ 'debug', 'info', 'warning', 'error', 'critical' ], true ) ? strtolower( $level ) : 'error'; $channel = $this->sanitize_identifier( $channel, 'core', 100, true ); $event = $this->make_event_relative( $channel, strtolower( trim( $event ) ) ); $event = $this->sanitize_identifier( $event, 'legacy_log', 150, false ); $context = $this->sanitizer->context( $context ); $message = $this->sanitizer->message( $message ); $now = current_time( 'mysql', true ); $record = [ 'created_at' => $now, 'level' => $level, 'channel' => $channel, 'event' => $event, 'message' => $message, 'context' => $this->encode_context( $context ), 'request_id' => AO_Request_Context::request_id(), 'user_id' => AO_Request_Context::user_id(), 'url' => AO_Request_Context::url( $this->sanitizer ), 'ao_version' => defined( 'ADMINOPTIMIZER_VERSION' ) ? (string) ADMINOPTIMIZER_VERSION : 'unknown', 'fingerprint' => $this->sanitizer->fingerprint( $level, $channel, $event, $context ), 'occurrence_count' => 1, 'first_seen_at' => $now, 'last_seen_at' => $now, ]; $should_store = in_array( $level, [ 'warning', 'error', 'critical' ], true ); $should_store = (bool) apply_filters( 'adminoptim_logger_should_store', $should_store, $record ); $stored = true; if ( $should_store ) { try { $stored = $this->storage->write( $record ); } catch ( \Throwable $exception ) { // Storage failures must never escape or be logged recursively. $stored = false; } } if ( $this->is_verbose() || ! $stored ) { $this->fallback( $level, $message, $context ); } if ( $should_store && $stored ) { do_action( 'adminoptim_log_recorded', $record ); } return $stored; } /** * Logs a handled Throwable, WP_Error, PHP/HTTP/DB error value. * * @param mixed $exception Error value. * @param array $context Additional context. */ public function exception( $exception, string $event = 'exception', array $context = [] ): bool { $details = $this->exception_details( $exception ); $message = isset( $details['exception_message'] ) ? (string) $details['exception_message'] : 'Unknown error'; if ( ! $this->is_verbose() ) { unset( $details['trace'] ); } return $this->record( 'error', $this->scoped_channel, $event, $message, array_merge( $context, $details ) ); } /** * @return array */ private function exception_details( $exception ): array { if ( $exception instanceof \Throwable ) { return [ 'exception_class' => get_class( $exception ), 'error_code' => $exception->getCode(), 'exception_message' => $exception->getMessage(), 'file' => $this->sanitizer->relative_path( $exception->getFile() ), 'line' => $exception->getLine(), 'trace' => $this->safe_trace( $exception ), ]; } if ( $exception instanceof \WP_Error ) { return [ 'exception_class' => 'WP_Error', 'error_code' => $exception->get_error_code(), 'exception_message' => $exception->get_error_message(), ]; } if ( is_array( $exception ) ) { $is_php_error = isset( $exception['type'], $exception['message'] ); return [ 'exception_class' => $is_php_error ? 'PHP_Error' : 'HTTP_Error', 'error_code' => $exception['code'] ?? $exception['errno'] ?? $exception['type'] ?? $exception['response']['code'] ?? null, 'exception_message' => $exception['message'] ?? $exception['error'] ?? $exception['response']['message'] ?? 'Unknown error', 'file' => isset( $exception['file'] ) ? $this->sanitizer->relative_path( (string) $exception['file'] ) : null, 'line' => isset( $exception['line'] ) ? (int) $exception['line'] : null, ]; } if ( is_object( $exception ) && isset( $exception->last_error ) ) { return [ 'exception_class' => get_class( $exception ), 'error_code' => $exception->last_errno ?? null, 'exception_message' => (string) $exception->last_error, ]; } return [ 'exception_class' => is_object( $exception ) ? get_class( $exception ) : gettype( $exception ), 'error_code' => null, 'exception_message' => is_scalar( $exception ) ? (string) $exception : 'Unknown error', ]; } /** * Returns a limited trace with arguments deliberately excluded. * * @return array> */ private function safe_trace( \Throwable $exception ): array { $trace = []; foreach ( array_slice( $exception->getTrace(), 0, 10 ) as $frame ) { $safe = []; foreach ( [ 'file', 'line', 'function', 'class', 'type' ] as $key ) { if ( isset( $frame[ $key ] ) ) { $safe[ $key ] = 'file' === $key ? $this->sanitizer->relative_path( (string) $frame[ $key ] ) : $frame[ $key ]; } } $trace[] = $safe; } return $trace; } /** * Normalizes a channel/event identifier to a safe, length-limited slug. * * @param string $value Raw identifier value. * @param string $fallback Value to use when sanitization empties the string. * @param int $limit Maximum length of the returned identifier. * @param bool $allow_dot Whether '.' is permitted in the sanitized value. * @return string */ private function sanitize_identifier( string $value, string $fallback, int $limit, bool $allow_dot ): string { $value = strtolower( trim( $value ) ); $regex = $allow_dot ? '/[^a-z0-9_.-]+/' : '/[^a-z0-9_-]+/'; $value = trim( (string) preg_replace( $regex, '_', $value ), '._-' ); return '' === $value ? $fallback : substr( $value, 0, $limit ); } /** * Strips a redundant channel prefix from an event name, if present. * * @param string $channel Channel the event belongs to. * @param string $event Raw event name. * @return string */ private function make_event_relative( string $channel, string $event ): string { foreach ( [ $channel . '.', strtok( $channel, '.' ) . '.', $channel . ':', strtok( $channel, '.' ) . ':' ] as $prefix ) { if ( 0 === strpos( $event, $prefix ) ) { $event = substr( $event, strlen( $prefix ) ); break; } } return '' === $event ? 'legacy_log' : $event; } /** * @param array $context Sanitized context. */ private function encode_context( array $context ): ?string { if ( [] === $context ) { return null; } $encoded = wp_json_encode( $context ); return is_string( $encoded ) && '' !== $encoded ? $encoded : null; } /** * @param array $context Sanitized context. */ private function fallback( string $level, string $message, array $context ): void { $line = sprintf( '[Admin Optimizer] %s: %s', strtoupper( $level ), $message ); if ( [] !== $context ) { $encoded = wp_json_encode( $context ); if ( is_string( $encoded ) && '' !== $encoded ) { $line .= ' ' . $encoded; } } error_log( $line ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } /** * Whether verbose (WP_DEBUG) logging is currently enabled. * * @return bool */ private function is_verbose(): bool { return defined( 'WP_DEBUG' ) && WP_DEBUG; } }