$context Optional. Extra detail. */ public function on_log( string $message, string $level = LogLevel::INFO, string $channel = Logger::ERRORS, array $context = array() ): void { $this->logger->log( $level, $channel, $message, $context ); } /** * Records mail the server refused to send. * * @param WP_Error $error Reason reported by PHPMailer. */ public function on_mail_failed( WP_Error $error ): void { $data = $error->get_error_data(); $data = is_array( $data ) ? $data : array(); $this->logger->wp_error( Logger::EMAIL, $error, array( // Addresses are the point of a mail failure, and are already in // the site's own mail server logs. 'to' => $this->addresses( $data['to'] ?? array() ), 'subject' => isset( $data['subject'] ) ? (string) $data['subject'] : '', ) ); } /** * Records the outcome of a notification. * * `wp_mail_failed` covers the failures the mailer reports. This covers the * other kind: a send that returned false without saying anything, which is * what a badly configured SMTP plugin tends to do. * * @param bool $sent Whether the mailer accepted the message. * @param Notification $notification Notification that was sent. * @param WP_Error|null $error Failure reported by the mailer, if any. */ public function on_mail_sent( bool $sent, Notification $notification, ?WP_Error $error = null ): void { if ( $sent ) { /* * Neither the subject nor the recipients are recorded on success. * * The subject is merge-tag expanded before it gets here, so the usual * `New enquiry from {full_name}` wrote a visitor's name into a log file * on every accepted submission — submitted data in a log the module's * own docblock promises never carries any. The recipient is worse for an * auto-reply, where it is the visitor's own address: a bulk, ongoing * record of who contacted the site, in a file the Tools screen offers as * a download. * * The counts are what an operator actually needs from a success line — * "it went out, to three people" — and neither identifies anybody. The * failure paths below still carry the detail, because there the reason * is the point and the volume is small. */ $this->logger->info( Logger::EMAIL, 'Notification handed to the mailer.', array( 'recipients' => count( $notification->to() ) ) ); return; } if ( $error instanceof WP_Error ) { // Already recorded in full by the wp_mail_failed handler. return; } $this->logger->error( Logger::EMAIL, 'The mailer refused the message without giving a reason.', array( 'to' => $this->addresses( $notification->to() ), 'subject' => $notification->subject(), ) ); } /** * Records a delivery that was given up on. * * @param int $id Delivery ID. * @param Webhook $webhook Endpoint that kept refusing. * @param array $result Response details of the last attempt. */ public function on_webhook_failed( int $id, Webhook $webhook, array $result = array() ): void { $this->logger->error( Logger::WEBHOOKS, 'Delivery abandoned after the last attempt was refused.', array( 'delivery' => $id, 'endpoint' => $webhook->name(), /* * The host, not the URL. For Slack, Discord, Zapier, Make and a * Sheets Apps Script deployment the full URL *is* the credential — * anyone holding it can post to that channel or workspace — and this * file is served from inside the uploads directory. The host is what * somebody diagnosing a failed delivery needs; the path is what they * must not be handed. */ 'host' => Redact::host( $webhook->url() ), 'code' => (int) ( $result['code'] ?? 0 ), 'reason' => (string) ( $result['message'] ?? '' ), ) ); } /** * Records a delivery that was accepted. * * @param int $id Delivery ID. * @param Webhook $webhook Endpoint that accepted it. * @param array $result Response details. */ public function on_webhook_delivered( int $id, Webhook $webhook, array $result = array() ): void { $this->logger->debug( Logger::WEBHOOKS, 'Delivery accepted.', array( 'delivery' => $id, 'endpoint' => $webhook->name(), 'code' => (int) ( $result['code'] ?? 0 ), 'ms' => (int) ( $result['ms'] ?? 0 ), ) ); } /** * Records a file that was refused. * * @param WP_Error $error Reason the file was refused. * @param string $key Field the file was submitted for. * @param string $stage Whether it failed being checked or being stored. */ public function on_upload_failed( WP_Error $error, string $key = '', string $stage = '' ): void { $this->logger->wp_error( Logger::UPLOADS, $error, array( 'field' => $key, 'stage' => $stage, ) ); } /** * Records a submission that did not pass its rules. * * Field keys and reason codes only. The messages a visitor sees can quote * what they typed, and what they typed is not going in a file. * * @param ValidationResult $result Validation result. * @param array $fields Fields of the form. */ public function on_validated( ValidationResult $result, array $fields = array() ): void { if ( $result->is_valid() ) { return; } $codes = array(); // Grouped by field, so each entry is a list of failures for that key. foreach ( $result->all() as $key => $errors ) { $first = reset( $errors ); if ( $first instanceof ValidationError ) { $codes[ (string) $key ] = $first->code(); } } $this->logger->info( Logger::VALIDATION, 'Submission refused by validation.', array( 'fields' => $codes ) ); } /** * Records a submission refused as spam. * * @param Form $form Form being submitted. * @param int $score Points the submission collected. * @param array $signals Points keyed by the reason that added them. */ public function on_spam( Form $form, int $score = 0, array $signals = array() ): void { $this->logger->warning( Logger::VALIDATION, 'Submission refused as spam.', array( 'form' => $form->id(), 'score' => $score, 'signals' => $signals, ) ); } /** * Records a write the database refused. * * @param string $error Message reported by the database. * @param string $operation Which write failed. * @param string $table Table being written to. */ public function on_database_error( string $error, string $operation = '', string $table = '' ): void { $this->logger->error( Logger::DATABASE, $error, array( 'operation' => $operation, 'table' => $table, ) ); } /** * Records a request to one of the plugin routes. * * Only the plugin's own namespace: this filter sees every REST request the * site serves, and logging all of them would fill the file with core's. * * @param WP_HTTP_Response $response Response about to be sent. * @param WP_REST_Server $server Server handling the request. * @param WP_REST_Request $request Request that was dispatched. * * @return WP_HTTP_Response The response, untouched. */ public function on_rest_dispatch( $response, $server, $request ) { if ( ! $request instanceof WP_REST_Request || ! str_starts_with( ltrim( $request->get_route(), '/' ), AbstractController::API_NAMESPACE . '/' ) ) { return $response; } $status = $response instanceof WP_HTTP_Response ? (int) $response->get_status() : 200; $context = array( 'method' => $request->get_method(), 'route' => $request->get_route(), 'status' => $status, 'user' => get_current_user_id(), ); // A refused request is worth an error; a served one is only detail. if ( $status >= 400 ) { $this->logger->error( Logger::REST, 'Request refused.', $context ); return $response; } $this->logger->debug( Logger::REST, 'Request served.', $context ); return $response; } /** * Removes log files past the retention window. */ public function purge(): void { $this->logger->store()->purge(); } /** * Flattens a recipient list to one string. * * @param mixed $to Recipients as PHPMailer reported them. */ private function addresses( mixed $to ): string { if ( is_array( $to ) ) { return implode( ', ', array_map( 'strval', $to ) ); } return is_scalar( $to ) ? (string) $to : ''; } }