$context Context. * @param string|null $error Error code. * @return int */ public function record( $ref_type, $ref_id, $from = null, $to = null, array $context = array(), $error = null ) { global $wpdb; $table = $wpdb->prefix . 'marginbeacon_operational_events'; $wpdb->insert( $table, array( 'correlation_id' => isset( $context['correlation_id'] ) ? (string) $context['correlation_id'] : '', 'ref_type' => (string) $ref_type, 'ref_id' => (string) $ref_id, 'transition_from' => $from, 'transition_to' => $to, 'event_at_gmt' => gmdate( 'Y-m-d H:i:s' ), 'duration_ms' => null, 'error_code' => $error, 'context_json' => wp_json_encode( $this->redact_context( $context ) ), 'retain_until_gmt' => gmdate( 'Y-m-d H:i:s', time() + ( 30 * DAY_IN_SECONDS ) ), ) ); return (int) $wpdb->insert_id; } /** * Recent transitions for Pro Operational Health (no customer PII). * * @param int $limit Max rows. * @return array> */ public function recent( $limit = 10 ) { global $wpdb; $limit = max( 1, min( 50, (int) $limit ) ); $table = $wpdb->prefix . 'marginbeacon_operational_events'; if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { return array(); } $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name from $wpdb->prefix. "SELECT id, ref_type, ref_id, transition_from, transition_to, event_at_gmt, error_code, context_json FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A ); if ( ! is_array( $rows ) ) { return array(); } $out = array(); foreach ( $rows as $row ) { $ctx = array(); if ( ! empty( $row['context_json'] ) ) { $decoded = json_decode( (string) $row['context_json'], true ); if ( is_array( $decoded ) ) { $ctx = $this->redact_context( $decoded ); } } $out[] = array( 'id' => (int) $row['id'], 'ref_type' => (string) $row['ref_type'], 'ref_id' => (string) $row['ref_id'], 'transition_from' => $row['transition_from'], 'transition_to' => $row['transition_to'], 'event_at_gmt' => (string) $row['event_at_gmt'], 'error_code' => $row['error_code'], 'context' => $ctx, ); } return $out; } /** * Strip keys that may hold PII from event context. * * @param array $context Context. * @return array */ private function redact_context( array $context ) { $blocked = array( 'email', 'billing_email', 'customer_email', 'phone', 'billing_phone', 'address', 'billing_address', 'shipping_address', 'first_name', 'last_name', 'customer_name', 'ip', 'ip_address', 'user_agent', ); $out = array(); foreach ( $context as $key => $value ) { $k = strtolower( (string) $key ); if ( in_array( $k, $blocked, true ) ) { continue; } if ( is_array( $value ) ) { $out[ $key ] = $this->redact_context( $value ); continue; } if ( is_string( $value ) && in_array( $k, array( 'reason', 'revoke_reason', 'note', 'message' ), true ) ) { $out[ $key ] = $this->sanitize_free_text( $value ); continue; } $out[ $key ] = $value; } return $out; } /** * @param string $text Raw free text. * @return string */ private function sanitize_free_text( $text ) { $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { return ''; } $text = preg_replace( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[redacted]', $text ); if ( strlen( $text ) > 500 ) { $text = substr( $text, 0, 497 ) . '...'; } return $text; } }