- New Cost_Tracker class: logs every API call (model, tokens, cost), enforces monthly budgets with hard-stop mode, provides aggregated reporting - New llm_costs database table with indexes on created_at, model, operation - Modified LLM class: call_openrouter_with_usage() extracts token usage from OpenRouter responses; budget gate checks before each API call - Admin settings: monthly budget (USD), enforcement mode (disabled/warn/hard-stop), cost log retention period - Admin cost dashboard: budget progress bar, daily cost chart (Chart.js), per-model and per-operation breakdowns, recent API call log - PHPUnit tests: 16 tests covering cost calculation, pricing lookups, budget enforcement logic, and model validation - Updated ARCHITECTURE.md with new table schema, data flow, and key decisions - Updated CONVENTIONS.md with "how to add cost tracking for a new operation" - Version bump to 2.4.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
366 lines
9.9 KiB
PHP
Executable File
366 lines
9.9 KiB
PHP
Executable File
<?php
|
|
declare( strict_types=1 );
|
|
/**
|
|
* Click analytics tracking and reporting.
|
|
*
|
|
* Handles recording click events, aggregating daily stats,
|
|
* and generating trend/topic reports.
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
class Peptide_News_Analytics {
|
|
|
|
/**
|
|
* Record a click event for an article.
|
|
*
|
|
* @param int $article_id
|
|
* @param array $meta Additional context (IP, UA, referrer, etc.).
|
|
* @return bool
|
|
*/
|
|
public static function record_click( int $article_id, array $meta = array() ): bool {
|
|
global $wpdb;
|
|
|
|
$table = $wpdb->prefix . 'peptide_news_clicks';
|
|
|
|
$ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip();
|
|
|
|
// Skip recording if no valid IP is available.
|
|
if ( null === $ip ) {
|
|
return false;
|
|
}
|
|
|
|
// Optionally anonymize IP (GDPR-friendly).
|
|
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
|
|
$ip = self::anonymize_ip( $ip );
|
|
}
|
|
|
|
$data = array(
|
|
'article_id' => absint( $article_id ),
|
|
'clicked_at' => current_time( 'mysql' ),
|
|
'user_ip' => sanitize_text_field( $ip ),
|
|
'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ),
|
|
'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ),
|
|
'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ),
|
|
'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ),
|
|
'user_id' => is_user_logged_in() ? get_current_user_id() : null,
|
|
'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ),
|
|
);
|
|
|
|
$result = $wpdb->insert( $table, $data );
|
|
|
|
// Update daily aggregate.
|
|
if ( false !== $result ) {
|
|
self::update_daily_stats( $article_id, $ip );
|
|
}
|
|
|
|
return false !== $result;
|
|
}
|
|
|
|
/**
|
|
* Update daily aggregated stats.
|
|
*
|
|
* @param int $article_id
|
|
* @param string $ip Anonymized IP for unique visitor counting.
|
|
*/
|
|
private static function update_daily_stats( int $article_id, string $ip ): void {
|
|
global $wpdb;
|
|
|
|
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
|
$today = current_time( 'Y-m-d' );
|
|
$clicks_tbl = $wpdb->prefix . 'peptide_news_clicks';
|
|
|
|
// Upsert daily click count.
|
|
$existing = $wpdb->get_row( $wpdb->prepare(
|
|
"SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s",
|
|
$article_id,
|
|
$today
|
|
) );
|
|
|
|
// Count unique IPs for today.
|
|
$unique = $wpdb->get_var( $wpdb->prepare(
|
|
"SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s",
|
|
$article_id,
|
|
$today
|
|
) );
|
|
|
|
if ( $existing ) {
|
|
$wpdb->update(
|
|
$table,
|
|
array(
|
|
'click_count' => $existing->click_count + 1,
|
|
'unique_visitors' => (int) $unique,
|
|
),
|
|
array( 'id' => $existing->id ),
|
|
array( '%d', '%d' ),
|
|
array( '%d' )
|
|
);
|
|
} else {
|
|
$wpdb->insert(
|
|
$table,
|
|
array(
|
|
'article_id' => $article_id,
|
|
'stat_date' => $today,
|
|
'click_count' => 1,
|
|
'unique_visitors' => 1,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get top articles by click count within a date range.
|
|
*
|
|
* @param string $start_date Y-m-d
|
|
* @param string $end_date Y-m-d
|
|
* @param int $limit
|
|
* @return array
|
|
*/
|
|
public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array {
|
|
global $wpdb;
|
|
|
|
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
|
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
|
|
|
return $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at,
|
|
SUM(s.click_count) AS total_clicks,
|
|
SUM(s.unique_visitors) AS total_unique
|
|
FROM {$stats_table} s
|
|
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
|
WHERE s.stat_date BETWEEN %s AND %s
|
|
GROUP BY a.id
|
|
ORDER BY total_clicks DESC
|
|
LIMIT %d",
|
|
$start_date,
|
|
$end_date,
|
|
$limit
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Get click trends over time (daily totals).
|
|
*
|
|
* @param string $start_date
|
|
* @param string $end_date
|
|
* @return array
|
|
*/
|
|
public static function get_click_trends( string $start_date, string $end_date ): array {
|
|
global $wpdb;
|
|
|
|
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
|
|
|
return $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
|
|
FROM {$table}
|
|
WHERE stat_date BETWEEN %s AND %s
|
|
GROUP BY stat_date
|
|
ORDER BY stat_date ASC",
|
|
$start_date,
|
|
$end_date
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Get popular topics/categories by click volume.
|
|
*
|
|
* @param string $start_date
|
|
* @param string $end_date
|
|
* @param int $limit
|
|
* @return array
|
|
*/
|
|
public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array {
|
|
global $wpdb;
|
|
|
|
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
|
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
|
|
|
// Get articles with their click counts.
|
|
$rows = $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks
|
|
FROM {$stats_table} s
|
|
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
|
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
|
|
GROUP BY a.id
|
|
ORDER BY clicks DESC",
|
|
$start_date,
|
|
$end_date
|
|
) );
|
|
|
|
// Aggregate by category.
|
|
$topics = array();
|
|
foreach ( $rows as $row ) {
|
|
$cats = array_map( 'trim', explode( ',', $row->categories ) );
|
|
foreach ( $cats as $cat ) {
|
|
if ( empty( $cat ) ) {
|
|
continue;
|
|
}
|
|
$cat_lower = strtolower( $cat );
|
|
if ( ! isset( $topics[ $cat_lower ] ) ) {
|
|
$topics[ $cat_lower ] = array(
|
|
'topic' => $cat,
|
|
'total_clicks' => 0,
|
|
'article_count' => 0,
|
|
);
|
|
}
|
|
$topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks;
|
|
$topics[ $cat_lower ]['article_count'] += 1;
|
|
}
|
|
}
|
|
|
|
// Sort by clicks.
|
|
usort( $topics, function ( $a, $b ) {
|
|
return $b['total_clicks'] - $a['total_clicks'];
|
|
} );
|
|
|
|
return array_slice( $topics, 0, $limit );
|
|
}
|
|
|
|
/**
|
|
* Get device breakdown stats.
|
|
*
|
|
* @param string $start_date
|
|
* @param string $end_date
|
|
* @return array
|
|
*/
|
|
public static function get_device_breakdown( string $start_date, string $end_date ): array {
|
|
global $wpdb;
|
|
|
|
$table = $wpdb->prefix . 'peptide_news_clicks';
|
|
|
|
return $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT device_type, COUNT(*) AS click_count
|
|
FROM {$table}
|
|
WHERE DATE(clicked_at) BETWEEN %s AND %s
|
|
GROUP BY device_type
|
|
ORDER BY click_count DESC",
|
|
$start_date,
|
|
$end_date
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Get source performance stats.
|
|
*
|
|
* @param string $start_date
|
|
* @param string $end_date
|
|
* @return array
|
|
*/
|
|
public static function get_source_performance( string $start_date, string $end_date ): array {
|
|
global $wpdb;
|
|
|
|
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
|
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
|
|
|
return $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT a.source,
|
|
COUNT(DISTINCT a.id) AS article_count,
|
|
SUM(s.click_count) AS total_clicks,
|
|
ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
|
|
FROM {$stats_table} s
|
|
INNER JOIN {$articles_table} a ON a.id = s.article_id
|
|
WHERE s.stat_date BETWEEN %s AND %s
|
|
GROUP BY a.source
|
|
ORDER BY total_clicks DESC",
|
|
$start_date,
|
|
$end_date
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Hard cap on CSV export rows to prevent memory exhaustion on large datasets.
|
|
*
|
|
* @var int
|
|
*/
|
|
const CSV_EXPORT_MAX_ROWS = 50000;
|
|
|
|
/**
|
|
* Export click data as CSV-ready array.
|
|
*
|
|
* Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites
|
|
* with large click volumes. The cap is applied at the SQL level so
|
|
* only the limited result set is ever loaded into PHP memory.
|
|
*
|
|
* @param string $start_date Y-m-d format.
|
|
* @param string $end_date Y-m-d format.
|
|
* @return array Array of associative arrays (one per click row).
|
|
*/
|
|
public static function export_clicks_csv( string $start_date, string $end_date ): array {
|
|
global $wpdb;
|
|
|
|
$clicks_table = $wpdb->prefix . 'peptide_news_clicks';
|
|
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
|
|
|
return $wpdb->get_results( $wpdb->prepare(
|
|
"SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories,
|
|
c.referrer_url, c.page_url, c.device_type, c.session_id
|
|
FROM {$clicks_table} c
|
|
INNER JOIN {$articles_table} a ON a.id = c.article_id
|
|
WHERE DATE(c.clicked_at) BETWEEN %s AND %s
|
|
ORDER BY c.clicked_at DESC
|
|
LIMIT %d",
|
|
$start_date,
|
|
$end_date,
|
|
self::CSV_EXPORT_MAX_ROWS
|
|
), ARRAY_A );
|
|
}
|
|
|
|
/**
|
|
* Get the client IP address.
|
|
*
|
|
* @return string|null
|
|
*/
|
|
private static function get_client_ip(): ?string {
|
|
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
|
|
|
|
foreach ( $ip_keys as $key ) {
|
|
if ( ! empty( $_SERVER[ $key ] ) ) {
|
|
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
|
|
$ip = trim( $ip[0] );
|
|
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6).
|
|
*
|
|
* @param string $ip
|
|
* @return string
|
|
*/
|
|
private static function anonymize_ip( string $ip ): string {
|
|
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
|
return preg_replace( '/\.\d+$/', '.0', $ip );
|
|
}
|
|
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
|
return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000';
|
|
}
|
|
return '0.0.0.0';
|
|
}
|
|
|
|
/**
|
|
* Simple device detection from user agent.
|
|
*
|
|
* @param string $ua
|
|
* @return string
|
|
*/
|
|
private static function detect_device( string $ua ): string {
|
|
$ua = strtolower( $ua );
|
|
|
|
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {
|
|
return 'tablet';
|
|
}
|
|
if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) {
|
|
return 'mobile';
|
|
}
|
|
if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) {
|
|
return 'bot';
|
|
}
|
|
|
|
return 'desktop';
|
|
}
|
|
}
|