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.
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.
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.
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
- MCP Adapter repository, GitHub
- Action Scheduler API documentation, GitHub
- Transients API, developer.wordpress.org