Observability

Real Performance Monitoring Under Agent Traffic

⏱ 17 min

Course 8’s observability lesson built a real McpObservabilityHandlerInterface implementation: call volume, latency from duration_ms, error rate bucketed by FailureReason, and per-ability usage. That’s real, useful monitoring, and it answers “is this ability slow” and “is this ability failing.” It doesn’t answer “is this ability expensive,” because cost was never part of that event stream, record_event() only knows about timing and outcome, not tokens or dollars. This lesson wires the cost log from earlier in this course into the same observability event, so latency and cost live on the same row instead of in two dashboards that never get compared, which is where the genuinely expensive problems hide: not the slowest ability, and not the most expensive one, but the ability that’s both.

What you'll learn in this lesson
Why latency-only and cost-only monitoring both miss the real problem
The abilities worth optimizing first are slow and expensive together, not either alone.
Tagging an observability event with cost data
Extending the tags array your handler already receives, rather than building a second system.
A combined query across the usage log and the metrics bucket
Joining Course 8's per-ability latency data with this course's per-call cost data.
Alerting on the combination, not either metric alone
A simple threshold check for "slow and expensive," run on a schedule.
Prerequisites

Course 8’s observability lesson (the McpObservabilityHandlerInterface implementation and its transient-bucket storage), and this course’s cost-monitoring dashboard lesson (the wp_ai_usage_log table). This lesson connects the two rather than replacing either.

Step 1: Two separate numbers hide the real problem

An ability that’s slow but cheap is usually a caching problem, Lesson 1 in this course. An ability that’s expensive but fast is usually a model-tier problem, the routing lesson two back. An ability that’s both slow and expensive is the one actually costing you the most, in both money and agent-perceived responsiveness, and it’s invisible if latency and cost live in two separate places that nobody ever queries together.

This is a monitoring gap, not a missing feature

Neither the MCP Adapter’s observability interface nor the PHP AI Client SDK has any concept of “cost” built into its event data. record_event() receives duration_ms, never a price. Combining the two is deliberate work this lesson does, the same way the cost log itself was deliberate work in the earlier lesson.

Step 2: Tag the observability event with cost, at the point you already have it

If an ability’s execute_callback triggers an AI call through the tracked wrapper from the cost-monitoring lesson, you already have an estimated cost figure the moment the call returns. Pass it through to record_event() as an additional tag, right alongside the ability tag the adapter already sets:

// File: my-plugin/class-product-copy-ability.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/generate-product-copy',
		array(
			// ...label, input_schema, permission_callback...
			'execute_callback' => function ( $input ) {
				$start = microtime( true );

				$result = my_plugin_generate_text_tracked(
					'product-copy',
					$input['prompt'],
					'Write concise e-commerce product copy.',
					'gemini-3-pro-preview'
				);

				$duration_ms = ( microtime( true ) - $start ) * 1000;
				$est_cost    = my_plugin_last_logged_cost( 'product-copy' );

				do_action( 'my_plugin_ai_call_completed', array(
					'ability'            => 'my-plugin/generate-product-copy',
					'duration_ms'        => $duration_ms,
					'estimated_cost_usd' => $est_cost,
				) );

				return $result;
			},
		)
	);
} );

my_plugin_last_logged_cost() is a small helper that reads back the row my_plugin_log_ai_usage() just inserted (by feature name and a recent timestamp) so the cost figure travels with the event rather than being recomputed. A custom my_plugin_ai_call_completed action decouples this ability’s code from whatever your observability handler does with it, the same separation record_event() itself gives you.

Step 3: Extend the handler from Course 8 to store cost alongside duration

Hook your existing handler’s storage logic (or the handler class itself, if you route this action into record_event() directly) to record cost in the same bucket as latency:

// File: includes/class-my-plugin-observability-handler.php
add_action( 'my_plugin_ai_call_completed', function ( array $data ) {
	$bucket = 'my_plugin_mcp_metrics_' . gmdate( 'YmdH' );
	$stats  = get_transient( $bucket ) ?: array();

	$ability = $data['ability'];
	$stats['by_ability_cost'][ $ability ]['calls']  = ( $stats['by_ability_cost'][ $ability ]['calls'] ?? 0 ) + 1;
	$stats['by_ability_cost'][ $ability ]['ms_sum']  = ( $stats['by_ability_cost'][ $ability ]['ms_sum'] ?? 0 ) + $data['duration_ms'];
	$stats['by_ability_cost'][ $ability ]['cost_sum'] = ( $stats['by_ability_cost'][ $ability ]['cost_sum'] ?? 0 ) + $data['estimated_cost_usd'];

	set_transient( $bucket, $stats, HOUR_IN_SECONDS * 2 );
} );

This extends the same hourly-bucket approach Course 8 used for by_ability, adding a by_ability_cost entry that tracks call count, summed latency, and summed cost together, per ability, per hour. Nothing about the original handler’s error-rate or failure-reason tracking changes.

Step 4: Find what’s actually both slow and expensive

With both figures in the same bucket, the query that actually matters is a ratio, not either raw number:

// File: my-plugin/class-cost-latency-report.php
function my_plugin_find_slow_and_expensive_abilities( string $bucket_key, float $ms_threshold, float $cost_threshold ): array {
	$stats = get_transient( $bucket_key );

	if ( ! is_array( $stats ) || empty( $stats['by_ability_cost'] ) ) {
		return array();
	}

	$flagged = array();

	foreach ( $stats['by_ability_cost'] as $ability => $row ) {
		$avg_ms   = $row['ms_sum'] / max( 1, $row['calls'] );
		$avg_cost = $row['cost_sum'] / max( 1, $row['calls'] );

		if ( $avg_ms >= $ms_threshold && $avg_cost >= $cost_threshold ) {
			$flagged[ $ability ] = array( 'avg_ms' => $avg_ms, 'avg_cost' => $avg_cost, 'calls' => $row['calls'] );
		}
	}

	return $flagged;
}

An ability with a high average latency and a low average cost, or the reverse, never gets flagged here, on purpose. This is specifically the “both” query, since either condition alone already has its own established lesson to address it, caching for latency, routing for cost.

An hourly average can hide a spike inside a quiet hour

Averaging across an hour smooths out a burst, ten expensive calls in one busy minute inside an otherwise quiet hour can still clear the threshold on average, but a genuinely rare, isolated spike might not. Treat this as a trend signal for prioritizing optimization work, not a real-time alerting system, if you need the latter, shorten the bucket window or move the check into the observability handler’s own real-time path instead of a periodic query.

Step 5: Run the check on a schedule, using Action Scheduler

This is exactly the kind of periodic, non-request-bound job the async lesson built Action Scheduler for:

// File: my-plugin.php
add_action( 'my_plugin_hourly_cost_latency_check', function () {
	$flagged = my_plugin_find_slow_and_expensive_abilities(
		'my_plugin_mcp_metrics_' . gmdate( 'YmdH', strtotime( '-1 hour' ) ),
		2000,  // 2 seconds average
		0.01   // 1 cent average per call
	);

	if ( ! empty( $flagged ) ) {
		error_log( 'Slow and expensive abilities this hour: ' . wp_json_encode( $flagged ) );
	}
} );

if ( false === as_next_scheduled_action( 'my_plugin_hourly_cost_latency_check' ) ) {
	as_schedule_single_action( strtotime( '+1 hour' ), 'my_plugin_hourly_cost_latency_check' );
}

Test it

Trigger a few calls through the tracked ability above, wait for the hour bucket to populate, then run the report directly:

wp-env run cli wp eval '
$flagged = my_plugin_find_slow_and_expensive_abilities( "my_plugin_mcp_metrics_" . gmdate("YmdH"), 500, 0.0001 );
var_dump( $flagged );
'

Lowering the thresholds like this in a test environment should surface the ability you just called, confirming both figures are actually landing in the same bucket.

Recap

Course 8 gave you latency and error rate. This course’s cost log gave you dollars. Neither the MCP Adapter’s observability contract nor the PHP AI Client SDK connects the two on its own, so this lesson tagged the same completed-call event with both figures and stored them in one bucket per ability, per hour. The report worth building from that data isn’t “slowest ability” or “most expensive ability” alone, it’s the intersection: what’s consistently both, run periodically through Action Scheduler rather than on every request.

Resources & further reading

← Rate Limiting AI Traffic at Scale Course Recap: A Production Checklist for AI at Scale →