Contents
Introduction
This article is a complete, detailed tutorial for logging user activity in WordPress using hooks in PHP. It covers architecture decisions, database schema, a production-ready plugin scaffold, example hook implementations (login, logout, registration, profile updates, failed logins, AJAX, comments, etc.), an admin UI for viewing and exporting logs, alternatives (custom post type, file-based logging), performance, security and GDPR considerations, and advanced patterns (background processing and REST endpoint). Code examples are provided and must be copied exactly into plugin files or mu-plugins each code sample is provided using the required
wrapper for PHP and other languages where noted.Why log user activity?
- Security — detect suspicious behavior, brute-force attempts, or compromised accounts.
- Auditing — maintain a record of who changed content, user roles, or settings.
- Support Debugging — understand what actions a user performed before encountering an issue.
- Analytics — augment analytics about user flows with server-side events.
Design choices and data model
Before implementing, choose where to store logs. The common options:
- Custom DB table — best for performance, controlled schema, large volume, and indexing.
- Custom post type — leverages WP APIs, good for small-medium volume, easy UI via admin posts list.
- File-based — simple, append-only logs for low volume or external ingestion watch file permissions.
- External service — ELK, Graylog, Sentry, or a cloud logging service for advanced aggregation and alerting.
Recommended schema for a custom table
A compact, indexed schema for high-volume logging:
column | type | notes |
id | BIGINT unsigned, AUTO_INCREMENT, PRIMARY KEY | surrogate key |
time | DATETIME | UTC timestamp |
user_id | BIGINT unsigned | 0 for guests |
user_login | VARCHAR(60) | username or display |
ip | VARBINARY(16) | store IPv4/IPv6 binary or hash |
user_agent | TEXT | trim length in inserts |
action | VARCHAR(100) | machine-friendly action name (login, logout, edit_post) |
object_type | VARCHAR(50) | post, comment, user, option, etc. |
object_id | BIGINT | nullable |
meta | LONGTEXT | JSON-encoded contextual data |
Plugin scaffold and table creation
Create a simple plugin main file (e.g., user-activity-logger.php). The following activation code creates the table. Adjust table name using wpdb->prefix and handle charset and collate.
prefix . user_activity_log } function ual_activate() { global wpdb, user_activity_logger_db_version table_name = ual_get_table_name() charset_collate = wpdb->get_charset_collate() sql = CREATE TABLE {table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, time DATETIME NOT NULL, user_id BIGINT UNSIGNED NOT NULL, user_login VARCHAR(60) DEFAULT , ip VARBINARY(16) DEFAULT NULL, user_agent TEXT, action VARCHAR(100) NOT NULL, object_type VARCHAR(50) DEFAULT NULL, object_id BIGINT DEFAULT NULL, meta LONGTEXT DEFAULT NULL, PRIMARY KEY (id), KEY user_id (user_id), KEY time (time), KEY action (action) ) {charset_collate} require_once ABSPATH . wp-admin/includes/upgrade.php dbDelta( sql ) add_option( ual_db_version, user_activity_logger_db_version ) }
Core helper to insert a log
Centralize writes to the log table. Use prepared statements to avoid SQL injection, and JSON-encode the meta. For IP privacy, you can hash or store partial IPs.
current_time( mysql, true ), // UTC user_id => 0, user_login => , ip => null, user_agent => , action => , object_type=> null, object_id => null, meta => null, ) r = wp_parse_args( args, defaults ) // Optionally anonymize IP for GDPR (e.g., store hash or truncated) ip_raw = r[ip] ?? ual_get_client_ip() ip_bin = null if ( ! empty( ip_raw ) ) { // Store as binary for IPv4/IPv6 efficiency: ip_bin = inet_pton( ip_raw ) if ( ip_bin === false ) { ip_bin = null } } meta = r[meta] ? wp_json_encode( r[meta] ) : null data = array( time => r[time], user_id => (int) r[user_id], user_login => substr( r[user_login], 0, 60 ), ip => ip_bin, user_agent => substr( r[user_agent], 0, 65535 ), action => substr( r[action], 0, 100 ), object_type => r[object_type], object_id => r[object_id] ? (int) r[object_id] : null, meta => meta, ) format = array( %s, %d, %s, %s, %s, %s, %s, %d, %s ) // Use wpdb->replace when wanting to upsert insert for append-only. inserted = wpdb->insert( table, data, format ) return inserted !== false }
Hooking common events
Hook into WordPress actions to capture relevant events. Examples below demonstrate key events and how to pass contextual data into ual_insert_log(). Add these to your plugin file.
Registration
user_id, user_login => user->user_login ?? , action => user_register, object_type=> user, object_id => user_id, meta => array( display_name => user->display_name ?? , roles => user->roles ?? array(), ), ) ) }
Login and failed login
user->ID, user_login => user_login, action => wp_login, meta => array( method => standard, ), ) ) } add_action( wp_login_failed, ual_wp_login_failed ) function ual_wp_login_failed( username ) { ual_insert_log( array( user_id => 0, user_login => username, action => wp_login_failed, meta => array( ip => ual_get_client_ip(), ), ) ) } add_action( wp_logout, ual_wp_logout ) function ual_wp_logout() { user = wp_get_current_user() ual_insert_log( array( user_id => user->ID ?? 0, user_login => user->user_login ?? , action => wp_logout, ) ) }
Profile updates and role changes
old_user_data->display_name ?? , new_display_name => user->display_name ?? , old_roles => old_user_data->roles ?? array(), new_roles => user->roles ?? array(), ) ual_insert_log( array( user_id => user_id, user_login => user->user_login ?? , action => profile_update, meta => meta, ) ) }
Post and comment actions
user->ID ?? 0, user_login => user->user_login ?? , action => update ? post_updated : post_created, object_type=> post, object_id => post_ID, meta => array( post_type => post->post_type, post_status => post->post_status ), ) ) } add_action( delete_post, ual_delete_post ) function ual_delete_post( post_id ) { user = wp_get_current_user() ual_insert_log( array( user_id => user->ID ?? 0, user_login => user->user_login ?? , action => post_deleted, object_type=> post, object_id => post_id, ) ) } add_action( comment_post, ual_comment_post, 10, 3 ) function ual_comment_post( comment_ID, comment_approved, commentdata ) { user = wp_get_current_user() ual_insert_log( array( user_id => user->ID ?? 0, user_login => user->user_login ?? , action => comment_created, object_type=> comment, object_id => comment_ID, meta => array( approved => comment_approved ), ) ) }
AJAX and custom actions
Log AJAX calls and custom actions. Always validate nonces for security.
user->ID ?? 0, user_login => user->user_login ?? , action => ajax_my_special_action, meta => array( request => _REQUEST ), ) ) wp_send_json_success( array( ok => true ) ) }
Admin UI to view and export logs
Provide a simple admin page using add_menu_page() to view logs with pagination and CSV export. The example below is minimal — in production add sanitization, searching, date filters and capability checks.
get_results( wpdb->prepare( SELECT FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, per_page ), ARRAY_A ) header( Content-Type: text/csv charset=utf-8 ) header( Content-Disposition: attachment filename=activity-log.csv ) output = fopen( php://output, w ) fputcsv( output, array_keys( rows[0] ?? array() ) ) foreach ( rows as row ) { // Convert binary IP to text if ( ! empty( row[ip] ) ) { row[ip] = inet_ntop( row[ip] ) } fputcsv( output, row ) } exit } total = (int) wpdb->get_var( SELECT COUNT() FROM {table} ) rows = wpdb->get_results( wpdb->prepare( SELECT FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, per_page ), ARRAY_A ) echo ltdiv class=wrapgt echo lth2gtUser Activity Logslt/h2gt echo lta href=?page=ual-logsampexport=csv class=buttongtExport CSV (page)lt/agt echo lttable class=wp-list-table widefat fixed stripedgt echo lttheadgtlttrgtltthgtTimelt/thgtltthgtUserlt/thgtltthgtActionlt/thgtltthgtObjectlt/thgtltthgtMetalt/thgtlt/trgtlt/theadgt echo lttbodygt foreach ( rows as row ) { ip_display = row[ip] ? @inet_ntop( row[ip] ) : meta = row[meta] ? wp_json_encode( json_decode( row[meta], true ) ) : echo lttrgt echo lttdgt . esc_html( row[time] ) . lt/tdgt echo lttdgt . esc_html( row[user_login] ) . ( . esc_html( row[user_id] ) . ) ltsmallgt . esc_html( ip_display ) . lt/smallgtlt/tdgt echo lttdgt . esc_html( row[action] ) . lt/tdgt echo lttdgt . esc_html( row[object_type] ) . . esc_html( row[object_id] ) . lt/tdgt echo lttdgt . esc_html( meta ) . lt/tdgt echo lt/trgt } echo lt/tbodygtlt/tablegt // Simple pagination links pages = ceil( total / per_page ) if ( pages > 1 ) { echo ltdiv class=tablenavgt for ( i = 1 i lt= pages i ) { class = i === paged ? class=page-numbers current : class=page-numbers echo lta . class . href=?page=ual-logsamppaged= . i . gt . i . lt/agt } echo lt/divgt } echo lt/divgt }
Alternative: use a custom post type
For small-to-moderate volume, storing logs as a private custom post type reduces custom UI work and benefits from WPs revision and meta APIs. Downsides: performance and table bloat.
User Logs, public => false, show_ui => true, supports => array( custom-fields ), capability_type => post, map_meta_cap => true, ) ) } // To create a log: function ual_cpt_insert_log( args = array() ) { post = array( post_type => user_log, post_title => args[action] ?? log, post_status => private, post_content => wp_json_encode( args[meta] ?? array() ), meta_input => array( time => current_time( mysql, true ), user_id => args[user_id] ?? 0, ), ) return wp_insert_post( post, true ) }
File-based logging (simple)
If you prefer to append events to a rotating file, ensure safe file locations, permissions, rotation and avoid writing from many PHP processes simultaneously without locking.
Performance and scaling
- Index common lookup columns (user_id, time, action).
- Batch writes — if you have high throughput, accumulate events and insert in batches using a background job (Action Scheduler or wp_cron).
- Avoid heavy work in hooks — keep the hook handler lightweight and defer heavy processing.
- Retention — prune old logs with scheduled cleanup to keep the table manageable.
- Compression — export and archive old logs externally if needed.
Example: schedule background insert via transient queue
Security, privacy, and GDPR considerations
- Use proper SQL preparation and the wpdb API to prevent injection.
- Escape HTML when outputting logs in the admin (esc_html(), esc_attr()).
- Limit who can view logs (manage_options or custom capability).
- Minimize personally identifiable information (PII) — consider hashing IPs or storing only truncated versions.
- Implement retention policies and deletion APIs in line with GDPR: allow manual deletion and scheduled purge.
- Document logging in your privacy policy and provide mechanisms to export or delete personal data.
- Be careful with user_agent and request payloads — they may contain sensitive tokens or data.
REST API endpoint (secure)
Expose logs via a REST route only to authorized users (e.g., users with manage_options or a specific capability), and sanitize output.
GET, callback => ual_rest_get_logs, permission_callback => function ( request ) { return current_user_can( manage_options ) }, ) ) } ) function ual_rest_get_logs( request ) { global wpdb table = ual_get_table_name() limit = min( 200, (int) ( request->get_param( per_page ) ?? 50 ) ) offset = max( 0, (int) ( request->get_param( offset ) ?? 0 ) ) rows = wpdb->get_results( wpdb->prepare( SELECT FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, limit ), ARRAY_A ) foreach ( rows as amprow ) { if ( ! empty( row[ip] ) ) { row[ip] = @inet_ntop( row[ip] ) } row[meta] = row[meta] ? json_decode( row[meta], true ) : null } return rest_ensure_response( rows ) }
Testing and debugging
- Test hooks by simulating actions (create user, login, update profile, create post, comment, AJAX).
- Use WP_DEBUG_LOG to capture plugin errors avoid writing debug to production logs.
- Load test your logging under expected concurrency to ensure table writes remain performant.
- Verify that logs do not contain secrets (API keys, passwords). Never log raw passwords.
Best practices checklist
- Define the events you need to log avoid logging everything indiscriminately.
- Prefer a custom table for volume and indexing use CPT for convenience if volume is low.
- Sanitize and prepare all data before inserting and escape on output.
- Provide a retention policy and mechanisms to delete/export data for privacy compliance.
- Keep hook handlers lightweight defer heavy work to background jobs.
- Restrict access to logs to authorized roles only and log access to the logs themselves if needed.
- Document the system and include logging practices in your privacy policy.
Notes on integration and further enhancements
- Action Scheduler or WP Background Processing can be used for reliable background processing at scale.
- Consider shipping logs to an external aggregator for centralized analysis and alerting.
- Add rich search and filters (by date range, user, action) to the admin UI for easier triage.
- Use prepared JSON schemas for meta to make processing easier downstream.
- Record user impersonation events (if using admin impersonation plugins) to preserve audit trails.
References
For function references and best practices, consult the WordPress Developer resources like developer.wordpress.org. Also consider PHP functions inet_pton/inet_ntop for IP handling and wp_json_encode/wp_json_decode for robust JSON handling within WordPress.
Summary
Logging user activity via WordPress hooks is straightforward: decide on storage, create a safe insertion helper, attach to relevant hooks, and build a minimal admin UI. Prioritize security, privacy and performance. The supplied code examples form a baseline plugin that you can extend with filtering, export, advanced UI and background processing to match production requirements.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |