2026年11月12日
2026年11月12日
How to Log User Activity in WordPress (Plugins and Custom Code)
This article is a localized edition of the Japanese original. Read the Japanese original
On sites with several administrators and editors, a record of who did what and when (an activity log or audit log) helps you trace mistakes and spot unauthorized logins. WordPress core has no such logging, so you either install a plugin or record the activity yourself with hooks.
Method 1: Use a plugin
Consider a plugin first. Both of the following are free plugins published in the official WordPress.org directory (as of November 2026).
| Plugin | Slug | Features |
|---|---|---|
| WP Activity Log | wp-security-audit-log |
Records logins, logouts and failed logins, post changes, user creation and role changes, plugin and theme actions, settings changes and more. The free version does not limit what is recorded, and log retention can be configured. Email notifications, reports, storing logs in an external database and similar features are in the paid version |
| Simple History | simple-history |
Records changes to posts, media, users, plugins, settings and more, plus failed logins, and shows them in a Dashboard widget and on a dedicated screen. IP addresses are partially masked by default. Logs are kept for 30 days by default |
If you have WP-CLI, you can install and activate them as follows. Searching by name under "Plugins" → "Add New" in the admin does the same.
# WP Activity Log
wp plugin install wp-security-audit-log --activate
# Simple History
wp plugin install simple-history --activate
Both start recording from the moment the plugin is activated. Actions taken before activation are not recorded.
Method 2: Record activity with your own plugin
If you only need to record a few things, or do not want another plugin, you can write to a dedicated table with hooks. Here we build a small plugin that records the following actions.
| Action recorded | Hook used |
|---|---|
| Login | wp_login |
| Logout | wp_logout |
| Failed login | wp_login_failed |
| Post and page updates | post_updated |
| Plugin activation and deactivation | activated_plugin / deactivated_plugin |
| User role changes (including the role set when a user is created) | set_user_role |
The table is created with the plugin activation hook (register_activation_hook()). That hook only works in a plugin file, so build this as a plugin rather than putting it in functions.php.
Create the plugin file
Create the folder wp-content/plugins/my-activity-log/ and save the following in it as my-activity-log.php.
<?php
/*
* Plugin Name: My Activity Log
* Description: Records logins, post updates and more in a dedicated table.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const MY_ACTIVITY_LOG_KEEP_DAYS = 90; // Number of days to keep logs
function my_activity_log_table() {
global $wpdb;
return $wpdb->prefix . 'user_activity_log';
}
// On activation, create the table and schedule a daily job that deletes old logs
register_activation_hook( __FILE__, function () {
global $wpdb;
$table = my_activity_log_table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
user_login varchar(60) NOT NULL DEFAULT '',
event_type varchar(50) NOT NULL DEFAULT '',
object_id bigint(20) unsigned NOT NULL DEFAULT 0,
detail varchar(255) NOT NULL DEFAULT '',
ip_address varchar(45) NOT NULL DEFAULT '',
user_agent varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (id),
KEY user_id (user_id),
KEY created_at (created_at)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
if ( ! wp_next_scheduled( 'my_activity_log_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'my_activity_log_cleanup' );
}
} );
// On deactivation, remove the schedule (the table and logs are kept)
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'my_activity_log_cleanup' );
} );
// Write one entry
function my_activity_log_write( $event_type, $user_id = 0, $user_login = '', $object_id = 0, $detail = '' ) {
global $wpdb;
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$wpdb->insert(
my_activity_log_table(),
array(
'created_at' => current_time( 'mysql', true ), // Stored in UTC
'user_id' => (int) $user_id,
'user_login' => mb_substr( (string) $user_login, 0, 60 ),
'event_type' => $event_type,
'object_id' => (int) $object_id,
'detail' => mb_substr( (string) $detail, 0, 255 ),
'ip_address' => mb_substr( $ip, 0, 45 ),
'user_agent' => mb_substr( $ua, 0, 255 ),
),
array( '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s' )
);
}
// Login
add_action( 'wp_login', function ( $user_login, $user ) {
my_activity_log_write( 'login', $user->ID, $user_login );
}, 10, 2 );
// Logout (WordPress 5.5 and later passes the user ID)
add_action( 'wp_logout', function ( $user_id ) {
$user = get_userdata( $user_id );
my_activity_log_write( 'logout', $user_id, $user ? $user->user_login : '' );
} );
// Failed login (records the username that was entered)
add_action( 'wp_login_failed', function ( $username ) {
my_activity_log_write( 'login_failed', 0, $username );
} );
// Post and page updates (excluding revisions and autosaves)
add_action( 'post_updated', function ( $post_id, $post_after, $post_before ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
$user = wp_get_current_user();
$detail = sprintf( '%s: %s -> %s', $post_after->post_type, $post_before->post_status, $post_after->post_status );
my_activity_log_write( 'post_updated', $user->ID, $user->user_login, $post_id, $detail );
}, 10, 3 );
// Plugin activation and deactivation
add_action( 'activated_plugin', function ( $plugin ) {
$user = wp_get_current_user();
my_activity_log_write( 'plugin_activated', $user->ID, $user->user_login, 0, $plugin );
} );
add_action( 'deactivated_plugin', function ( $plugin ) {
$user = wp_get_current_user();
my_activity_log_write( 'plugin_deactivated', $user->ID, $user->user_login, 0, $plugin );
} );
// Role changes
add_action( 'set_user_role', function ( $user_id, $role, $old_roles ) {
$user = wp_get_current_user();
$detail = sprintf( 'user %d: %s -> %s', $user_id, implode( ',', (array) $old_roles ), $role );
my_activity_log_write( 'role_changed', $user->ID, $user->user_login, $user_id, $detail );
}, 10, 3 );
// Delete old logs
add_action( 'my_activity_log_cleanup', function () {
global $wpdb;
$table = my_activity_log_table();
$cutoff = gmdate( 'Y-m-d H:i:s', time() - MY_ACTIVITY_LOG_KEEP_DAYS * DAY_IN_SECONDS );
$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) );
} );
After saving, activate "My Activity Log" under "Plugins" → "Installed Plugins" in the admin. The table (wp_user_activity_log if your prefix is wp_) is created on activation.
Notes on the code
- How to write
dbDelta()SQL.dbDelta()is strict about formatting: one column per line, two spaces afterPRIMARY KEY, andKEYrather thanINDEXfor indexes. WriteCREATE TABLE, notCREATE TABLE IF NOT EXISTS(if the table exists, only the differences are applied). - Times are stored in UTC.
current_time( 'mysql', true )returns the date and time in UTC. Convert to Japan time (+9 hours) when you display it. - The username recorded by
wp_login_failedis exactly what was entered. Usernames that do not exist and email addresses are recorded too. - IP addresses. If traffic goes through Cloudflare or a load balancer,
REMOTE_ADDRis the proxy's IP address. In that case, replace it with the real client address on the server side (in the web server configuration) before recording. Headers such asX-Forwarded-Forcan be set freely by the client, so do not trust them as-is. - Plugin activations are recorded only while this plugin is active. Deactivating this plugin itself is recorded, because its code is still loaded at that point, but nothing after that is recorded.
View the records
Show the 20 newest entries with WP-CLI (change the table name if your prefix is not wp_).
wp db query "SELECT id, CONVERT_TZ(created_at, '+00:00', '+09:00') AS jst, user_login, event_type, object_id, detail, ip_address FROM wp_user_activity_log ORDER BY id DESC LIMIT 20;"
An example that finds the IP addresses with the most failed logins:
wp db query "SELECT ip_address, COUNT(*) AS cnt FROM wp_user_activity_log WHERE event_type = 'login_failed' AND created_at > UTC_TIMESTAMP() - INTERVAL 1 DAY GROUP BY ip_address ORDER BY cnt DESC LIMIT 10;"
If WP-CLI is not available, run the same SQL in phpMyAdmin.
When you no longer need it
Deactivating the plugin leaves the table and logs in place. To delete them, back up the database first and then run:
wp db query "DROP TABLE wp_user_activity_log;"
Things to watch for
- Personal data. Logs tied to IP addresses and usernames may fall under Japan's Act on the Protection of Personal Information or the GDPR. Decide why you record them and how long you keep them, and state this in your privacy policy. In the code above, the retention period is 90 days (
MY_ACTIVITY_LOG_KEEP_DAYS). - The logs live in the same database. If someone takes over an administrator account, they may delete or alter the logs as well. If you also want to use the logs to investigate unauthorized access, keep the web server's access logs separately, or consider a way to send logs off-site (such as log forwarding in the paid version of WP Activity Log).
- Too many records make the database grow. Failed logins are recorded in large numbers during an attack. Protect the login screen against brute force attempts as well.
Summary
- For a quick start, use WP Activity Log or Simple History.
- To record activity yourself, write to a dedicated table from hooks such as
wp_login,wp_logout,wp_login_failedandpost_updated. The table is created by the activation hook, so build it as a plugin. - Set a retention period, delete old logs automatically, and describe how you handle them as personal data in your privacy policy.
To protect the login screen, see also protecting against brute force attacks.
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)