Implement server-side remote error logging (#49599)

* Implement php remote logging

* Log fatal errors remotly

* Add tests

* Add changelog

* Improve code doc and type check

* Bug fixs

Fix Undefined variable $local_logger

* Rebase

* Fix tests

Fix tests

* Fix import path

* Use WC_Site_Tracking::is_tracking_enabled()

* Simplfy path checking/replace logic

* Rename log method -> handle

* Handle wp_safe_remote_post fails

* Fix lint

* Update plugins/woocommerce/includes/class-woocommerce.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Internal/Logging/RemoteLogger.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Use WC_Rate_Limiter

* Update wc_doing_it_wrong log method name

* Update is_third_party_error method signature in RemoteLogger.php

* Revert changes

* Update plugins/woocommerce/src/Internal/Logging/RemoteLogger.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Internal/Logging/RemoteLogger.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Internal/Logging/RemoteLogger.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Internal/Logging/RemoteLogger.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* chore: Add 'php' tag to RemoteLogger in woocommerce

* Fix test

---------

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
This commit is contained in:
Chi-Hsuan Huang 2024-08-01 12:56:14 +08:00 committed by GitHub
parent 76e1761cf7
commit e29d14b12e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 618 additions and 11 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Implement server-side remote error logging

View File

@ -29,6 +29,7 @@ use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\McStats;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil};
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
/**
* Main WooCommerce Class.
@ -409,6 +410,9 @@ final class WooCommerce {
$mc_stats->add( 'error', 'fatal-errors-during-shutdown' );
$mc_stats->do_server_side_stats();
$remote_logger = $container->get( RemoteLogger::class );
$remote_logger->handle( time(), WC_Log_Levels::CRITICAL, $message, $context );
/**
* Action triggered when there are errors during shutdown.
*

View File

@ -4,6 +4,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Logging;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use WC_Rate_Limiter;
/**
* WooCommerce Remote Logger
@ -16,10 +18,182 @@ use Automattic\WooCommerce\Utilities\FeaturesUtil;
* @since 9.2.0
* @package WooCommerce\Classes
*/
class RemoteLogger {
class RemoteLogger extends \WC_Log_Handler {
const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash';
const RATE_LIMIT_ID = 'woocommerce_remote_logging';
const RATE_LIMIT_DELAY = 60; // 1 minute.
const WC_LATEST_VERSION_TRANSIENT = 'latest_woocommerce_version';
const FETCH_LATEST_VERSION_RETRY = 'fetch_latest_woocommerce_version_retry';
/**
* The logger instance.
*
* @var \WC_Logger_Interface|null
*/
private $local_logger;
/**
* Remote logger constructor.
*
* @internal
* @param \WC_Logger_Interface|null $logger Logger instance.
*/
public function __construct( \WC_Logger_Interface $logger = null ) {
$this->local_logger = null === $logger
? wc_get_logger()
: $logger;
}
/**
* Handle a log entry.
*
* @param int $timestamp Log timestamp.
* @param string $level emergency|alert|critical|error|warning|notice|info|debug.
* @param string $message Log message.
* @param array $context Additional information for log handlers.
*
* @throws \Exception If the remote logging fails. The error is caught and logged locally.
*
* @return bool False if value was not handled and true if value was handled.
*/
public function handle( $timestamp, $level, $message, $context ) {
if ( ! \WC_Log_Levels::is_valid_level( $level ) ) {
/* translators: 1: WC_Remote_Logger::log 2: level */
wc_doing_it_wrong( __METHOD__, sprintf( __( '%1$s was called with an invalid level "%2$s".', 'woocommerce' ), '<code>WC_Remote_Logger::handle</code>', $level ), '9.2.0' );
}
if ( ! $this->is_remote_logging_allowed() ) {
return false;
}
if ( $this->is_third_party_error( (string) $message, (array) $context ) ) {
return false;
}
if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) {
$this->local_logger->info( 'Remote logging throttled.', array( 'source' => 'wc-remote-logger' ) );
return false;
}
try {
$log_data = $this->get_formatted_log( $level, $message, $context );
// Ensure the log data is valid.
if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) {
return false;
}
$body = array(
'params' => wp_json_encode( $log_data ),
);
WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY );
$response = wp_safe_remote_post(
self::LOG_ENDPOINT,
array(
'body' => wp_json_encode( $body ),
'timeout' => 2,
'headers' => array(
'Content-Type' => 'application/json',
),
'blocking' => false,
)
);
if ( is_wp_error( $response ) ) {
throw new \Exception( $response->get_error_message() );
}
return true;
} catch ( \Exception $e ) {
// Log the error locally if the remote logging fails.
$this->local_logger->error( 'Remote logging failed: ' . $e->getMessage() );
return false;
}
}
/**
* Get formatted log data to be sent to the remote logging service.
*
* This method formats the log data by sanitizing the message, adding default fields, and including additional context
* such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details.
* The formatted log data is then filtered before being sent to the remote logging service.
*
* @param string $level Log level (e.g., 'error', 'warning', 'info').
* @param string $message Log message to be recorded.
* @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'.
*
* @return array Formatted log data ready to be sent to the remote logging service.
*/
public function get_formatted_log( $level, $message, $context = array() ) {
$log_data = array(
// Default fields.
'feature' => 'woocommerce_core',
'severity' => $level,
'message' => $this->sanitize( $message ),
'host' => wp_parse_url( home_url(), PHP_URL_HOST ),
'tags' => array( 'woocommerce', 'php' ),
'properties' => array(
'wc_version' => WC()->version,
'php_version' => phpversion(),
'wp_version' => get_bloginfo( 'version' ),
),
);
if ( isset( $context['backtrace'] ) ) {
if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) {
$log_data['trace'] = $this->sanitize_trace( $context['backtrace'] );
} elseif ( true === $context['backtrace'] ) {
$log_data['trace'] = $this->sanitize_trace( self::get_backtrace() );
}
unset( $context['backtrace'] );
}
if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) {
$log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] );
unset( $context['tags'] );
}
if ( class_exists( '\WC_Tracks' ) ) {
$user = wp_get_current_user();
$blog_details = \WC_Tracks::get_blog_details( $user->ID );
if ( is_numeric( $blog_details['blog_id'] ) && $blog_details['blog_id'] > 0 ) {
$log_data['blog_id'] = $blog_details['blog_id'];
}
if ( ! empty( $blog_details['store_id'] ) ) {
$log_data['properties']['store_id'] = $blog_details['store_id'];
}
}
if ( isset( $context['error'] ) && is_array( $context['error'] ) && ! empty( $context['error']['file'] ) ) {
$context['error']['file'] = $this->sanitize( $context['error']['file'] );
}
$extra_attrs = $context['extra'] ?? array();
unset( $context['extra'] );
// Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash.
$log_data['extra'] = array_merge( $extra_attrs, $context );
/**
* Filters the formatted log data before sending it to the remote logging service.
* Returning a non-array value will prevent the log from being sent.
*
* @since 9.2.0
*
* @param array $log_data The formatted log data.
* @param string $level The log level (e.g., 'error', 'warning').
* @param string $message The log message.
* @param array $context The original context array.
*
* @return array The filtered log data.
*/
return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context );
}
/**
* Determines if remote logging is allowed based on the following conditions:
*
@ -35,7 +209,7 @@ class RemoteLogger {
return false;
}
if ( ! $this->is_tracking_opted_in() ) {
if ( ! \WC_Site_Tracking::is_tracking_enabled() ) {
return false;
}
@ -50,15 +224,6 @@ class RemoteLogger {
return true;
}
/**
* Check if the user has opted into tracking/logging.
*
* @return bool
*/
private function is_tracking_opted_in() {
return 'yes' === get_option( 'woocommerce_allow_tracking', 'no' );
}
/**
* Check if the store is allowed to log based on the variant assignment percentage.
*
@ -84,6 +249,55 @@ class RemoteLogger {
return version_compare( WC()->version, $latest_wc_version, '>=' );
}
/**
* Check if the error exclusively contains third-party stack frames for fatal-errors source context.
*
* @param string $message The error message.
* @param array $context The error context.
*
* @return bool
*/
protected function is_third_party_error( string $message, array $context ): bool {
// Only check for fatal-errors source context.
if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) {
return false;
}
// If backtrace is not available, we can't determine if the error is third-party. Log it for further investigation.
if ( ! isset( $context['backtrace'] ) || ! is_array( $context['backtrace'] ) ) {
return false;
}
$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
// Check if the error message contains the WooCommerce plugin directory.
if ( str_contains( $message, $wc_plugin_dir ) ) {
return false;
}
// Check if the backtrace contains the WooCommerce plugin directory.
foreach ( $context['backtrace'] as $trace ) {
if ( is_string( $trace ) && str_contains( $trace, $wc_plugin_dir ) ) {
return false;
}
if ( is_array( $trace ) && isset( $trace['file'] ) && str_contains( $trace['file'], $wc_plugin_dir ) ) {
return false;
}
}
/**
* Filter to allow other plugins to overwrite the result of the third-party error check for remote logging.
*
* @since 9.2.0
*
* @param bool $is_third_party_error The result of the third-party error check.
* @param string $message The error message.
* @param array $context The error context.
*/
return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context );
}
/**
* Fetch the latest WooCommerce version using the WordPress API and cache it.
*
@ -125,4 +339,72 @@ class RemoteLogger {
return null;
}
/**
* Sanitize the content to exclude sensitive data.
*
* The trace is sanitized by:
*
* 1. Remove the absolute path to the WooCommerce plugin directory.
* 2. Remove the absolute path to the WordPress root directory.
*
* For example, the trace:
*
* /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123
* will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123
*
* @param string $message The message to sanitize.
* @return string The sanitized message.
*/
private function sanitize( $message ) {
if ( ! is_string( $message ) ) {
return $message;
}
$wc_path = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
$wp_path = StringUtil::normalize_local_path_slashes( ABSPATH );
$sanitized = str_replace(
array( $wc_path, $wp_path ),
array( '**/' . dirname( WC_PLUGIN_BASENAME ) . '/', '**/' ),
$message
);
return $sanitized;
}
/**
* Sanitize the error trace to exclude sensitive data.
*
* @param array|string $trace The error trace.
* @return string The sanitized trace.
*/
private function sanitize_trace( $trace ): string {
if ( is_string( $trace ) ) {
return $this->sanitize( $trace );
}
if ( ! is_array( $trace ) ) {
return '';
}
$sanitized_trace = array_map(
function ( $trace_item ) {
if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) {
$trace_item['file'] = $this->sanitize( $trace_item['file'] );
return $trace_item;
}
return $this->sanitize( $trace_item );
},
$trace
);
$is_array_by_file = isset( $sanitized_trace[0]['file'] );
if ( $is_array_by_file ) {
return wc_print_r( $sanitized_trace, true );
}
return implode( "\n", $sanitized_trace );
}
}

View File

@ -4,6 +4,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Logging;
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
* Class RemoteLoggerTest.
@ -41,6 +43,13 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
delete_option( 'woocommerce_feature_remote_logging_enabled' );
delete_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT );
global $wpdb;
$wpdb->query(
"DELETE FROM {$wpdb->prefix}wc_rate_limits"
);
WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP );
}
/**
@ -54,6 +63,8 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
remove_all_filters( 'option_woocommerce_version' );
remove_all_filters( 'option_woocommerce_remote_variant_assignment' );
remove_all_filters( 'plugins_api' );
remove_all_filters( 'pre_http_request' );
remove_all_filters( 'woocommerce_remote_logger_formatted_log_data' );
}
/**
@ -233,4 +244,310 @@ class RemoteLoggerTest extends \WC_Unit_Test_Case {
$this->sut->is_remote_logging_allowed();
$this->assertEquals( 3, get_transient( RemoteLogger::FETCH_LATEST_VERSION_RETRY ) );
}
/**
* @testdox Test get_formatted_log method with basic log data returns expected array.
*/
public function test_get_formatted_log_basic() {
$level = 'error';
$message = 'Fatal error occurred at line 123 in ' . ABSPATH . 'wp-content/file.php';
$context = array( 'tags' => array( 'tag1', 'tag2' ) );
$formatted_log = $this->sut->get_formatted_log( $level, $message, $context );
$this->assertArrayHasKey( 'feature', $formatted_log );
$this->assertArrayHasKey( 'severity', $formatted_log );
$this->assertArrayHasKey( 'message', $formatted_log );
$this->assertArrayHasKey( 'host', $formatted_log );
$this->assertArrayHasKey( 'tags', $formatted_log );
$this->assertArrayHasKey( 'properties', $formatted_log );
$this->assertEquals( 'woocommerce_core', $formatted_log['feature'] );
$this->assertEquals( 'error', $formatted_log['severity'] );
$this->assertEquals( 'Fatal error occurred at line 123 in **/wp-content/file.php', $formatted_log['message'] );
$this->assertEquals( wp_parse_url( home_url(), PHP_URL_HOST ), $formatted_log['host'] );
// Tags.
$this->assertArrayHasKey( 'tags', $formatted_log );
$this->assertEquals( array( 'woocommerce', 'php', 'tag1', 'tag2' ), $formatted_log['tags'] );
// Properties.
$this->assertEquals( WC()->version, $formatted_log['properties']['wc_version'] );
$this->assertEquals( get_bloginfo( 'version' ), $formatted_log['properties']['wp_version'] );
$this->assertEquals( phpversion(), $formatted_log['properties']['php_version'] );
}
/**
* @testdox Test get_formatted_log method sanitizes backtrace.
*/
public function test_get_formatted_log_with_backtrace() {
$level = 'error';
$message = 'Test error message';
$context = array( 'backtrace' => ABSPATH . 'wp-content/file.php' );
$result = $this->sut->get_formatted_log( $level, $message, $context );
$this->assertEquals( '**/wp-content/file.php', $result['trace'] );
$context = array( 'backtrace' => ABSPATH . 'wp-content/plugins/woocommerce/file.php' );
$result = $this->sut->get_formatted_log( $level, $message, $context );
$this->assertEquals( '**/woocommerce/file.php', $result['trace'] );
$context = array( 'backtrace' => true );
$result = $this->sut->get_formatted_log( $level, $message, $context );
$this->assertIsString( $result['trace'] );
$this->assertStringContainsString( '**/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php', $result['trace'] );
}
/**
* @testdox Test get_formatted_log method log extra attributes.
*/
public function test_get_formatted_log_with_extra() {
$level = 'error';
$message = 'Test error message';
$context = array(
'extra' => array(
'key1' => 'value1',
'key2' => 'value2',
),
);
$result = $this->sut->get_formatted_log( $level, $message, $context );
$this->assertArrayHasKey( 'extra', $result );
$this->assertEquals( 'value1', $result['extra']['key1'] );
$this->assertEquals( 'value2', $result['extra']['key2'] );
}
/**
* @testdox Test handle() method when throttled.
*
* @return void
*/
public function test_handle_returns_false_when_throttled() {
$mock_local_logger = $this->createMock( \WC_Logger::class );
$mock_local_logger->expects( $this->once() )
->method( 'info' )
->with( 'Remote logging throttled.', array( 'source' => 'wc-remote-logger' ) );
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->setConstructorArgs( array( $mock_local_logger ) )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
// Set rate limit to simulate exceeded limit.
WC_Rate_Limiter::set_rate_limit( RemoteLogger::RATE_LIMIT_ID, 10 );
$result = $this->sut->handle( time(), 'error', 'Test message', array() );
$this->assertFalse( $result );
}
/**
* @testdox Test handle() method applies filter.
*
* @return void
*/
public function test_handle_filtered_log_null() {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
add_filter(
'woocommerce_remote_logger_formatted_log_data',
function ( $log_data, $level, $message, $context ) {
$this->assertEquals( 'Test message', $log_data['message'] );
$this->assertEquals( 'error', $level );
$this->assertEquals( 'Test message', $message );
$this->assertEquals( array(), $context );
return null;
},
10,
4
);
// Mock wp_safe_remote_post using pre_http_request filter.
add_filter(
'pre_http_request',
function () {
// assert not called.
$this->assertFalse( true );
},
10,
3
);
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
}
/**
* @testdox Test handle() method successfully sends log.
*/
public function test_handle_successful() {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
// Mock wp_safe_remote_post using pre_http_request filter.
add_filter(
'pre_http_request',
function ( $preempt, $args, $url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$this->assertArrayHasKey( 'body', $args );
$this->assertArrayHasKey( 'headers', $args );
return array(
'response' => array(
'code' => 200,
'message' => 'OK',
),
'body' => wp_json_encode( array( 'success' => true ) ),
);
},
10,
3
);
$this->assertTrue( $this->sut->handle( time(), 'error', 'Test message', array() ) );
// Verify that rate limit was set.
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox Test handle method when remote logging fails.
**/
public function test_handle_remote_logging_failure() {
$mock_local_logger = $this->createMock( \WC_Logger::class );
$mock_local_logger->expects( $this->once() )
->method( 'error' );
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->setConstructorArgs( array( $mock_local_logger ) )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
// Mock wp_safe_remote_post to throw an exception using pre_http_request filter.
add_filter(
'pre_http_request',
function ( $preempt, $args, $url ) {
if ( 'https://public-api.wordpress.com/rest/v1.1/logstash' === $url ) {
throw new \Exception( 'Remote logging failed: A valid URL was not provided.' );
}
return $preempt;
},
10,
3
);
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
// Verify that rate limit was set.
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox Test is_third_party_error method.
*
* @dataProvider data_provider_for_is_third_party_error
*
* @param string $message Error message.
* @param array $context Context.
* @param bool $expected_result Expected result.
*/
public function test_is_third_party_error( $message, $context, $expected_result ) {
$result = $this->invoke_private_method( $this->sut, 'is_third_party_error', array( $message, $context ) );
$this->assertEquals( $expected_result, $result );
}
/**
* Data provider for third party error test.
*
* @return array
*/
public function data_provider_for_is_third_party_error() {
return array(
array(
'Fatal error occurred at line 123 in' . WC_ABSPATH . 'file.php',
array(),
false,
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/file.php',
array(),
false, // source is not.
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/file.php',
array( 'source' => 'fatal-errors' ),
false, // backtrace is not set.
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array(
'/home/user/path/wp-content/plugins/3rd-plugin/file.php',
WC_ABSPATH . 'file.php',
),
),
false,
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/plugins/woocommerce-3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array(
WP_PLUGIN_DIR . 'woocommerce-3rd-plugin/file.php',
),
),
true,
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array(
WP_PLUGIN_DIR . '3rd-plugin/file.php',
),
),
true,
),
array(
'Fatal error occurred at line 123 in /home/user/path/wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array(
array(
'file' => WP_PLUGIN_DIR . '3rd-plugin/file.php',
),
),
),
true,
),
);
}
/**
* Helper method to invoke private methods.
*
* @param object $obj Object instance.
* @param string $method_name Name of the private method.
* @param array $parameters Parameters to pass to the method.
* @return mixed
*/
private function invoke_private_method( $obj, $method_name, $parameters = array() ) {
$reflection = new \ReflectionClass( get_class( $obj ) );
$method = $reflection->getMethod( $method_name );
$method->setAccessible( true );
return $method->invokeArgs( $obj, $parameters );
}
}