Tracking Computer Activity with Beeminder: A Linux Automation Story

Today I configured my Debian Linux machine to log my activity locally and then post my daily active time to a Beeminder goal. I used Warp and Codebuff to help me set everything up and write out the following post detailing what it did.

:robot: Everything beyond this point is AI-generated :robot:


Tracking Computer Activity with Beeminder: A Linux Automation Story

Have you ever wondered how much time you actually spend actively using your computer each day? I wanted to answer this question for myself, and being a fan of Beeminder for habit tracking, I decided to build a system that would automatically track my computer usage and post it to Beeminder. In this post, I’ll walk through how I set up this automation using bash scripts, systemd, and cron.

System Architecture Overview

My activity tracking system consists of four key components:

  1. Activity Monitor Script: Collects data about my computer usage every minute
  2. Activity Log File: Stores the collected data with timestamps
  3. Daily Active Time Script: Parses the logs to calculate my active time
  4. Beeminder Posting Script: Sends the calculated time to Beeminder

The system is fully automated using systemd to run the activity monitor every minute and cron to trigger the Beeminder update script hourly.

The Activity Monitor Script

The heart of the system is activity_monitor.sh, which captures what I’m doing on my computer:

#!/bin/bash

LOG_FILE="$HOME/.activity_log"
DATE=$(date "+%Y-%m-%d %H:%M:%S")

# Get current active window information using xdotool
WINDOW_ID=$(xdotool getwindowfocus)
WINDOW_TITLE=$(xdotool getwindowfocus getwindowname)

# Get PID and application name of the window
WINDOW_PID=$(xprop -id $WINDOW_ID _NET_WM_PID 2>/dev/null | grep -o '[0-9]*$')
if [ -n "$WINDOW_PID" ]; then
    APP_NAME=$(ps -p $WINDOW_PID -o comm= 2>/dev/null || echo "Unknown")
else
    APP_NAME="Unknown"
fi

# Get idle time in milliseconds if xprintidle is installed
if command -v xprintidle &> /dev/null; then
    IDLE_TIME=$(xprintidle)
    IDLE_SEC=$((IDLE_TIME/1000))
else
    IDLE_SEC="xprintidle not installed"
fi

# Get system load
LOAD=$(cat /proc/loadavg | cut -d " " -f 1-3)

# Log the information
echo "$DATE | App: $APP_NAME | Window: $WINDOW_TITLE | Idle: $IDLE_SEC sec | Load: $LOAD" >> "$LOG_FILE"

This script uses xdotool to detect the currently focused window and xprintidle to track how long I’ve been idle. Every minute, it logs:

  • The current date and time
  • The application name
  • The window title
  • How long I’ve been idle (in seconds)
  • The system load

Each entry is appended to ~/.activity_log with a timestamp, creating a detailed record of my computer usage throughout the day.

The Activity Log File

The log file (~/.activity_log) is a simple text file that accumulates entries like this:

2023-11-08 14:30:45 | App: firefox | Window: Gmail - Mozilla Firefox | Idle: 12 sec | Load: 0.52 0.48 0.45
2023-11-08 14:31:45 | App: code | Window: activity_monitor.sh - VS Code | Idle: 3 sec | Load: 0.67 0.52 0.47

This format makes it easy to parse and analyze later. While I’m currently only using it for daily active time calculations, having this detailed log opens up possibilities for deeper analysis in the future.

Calculating Daily Active Time

The daily_active_time.sh script processes the log file to determine how much time I’ve spent actively using my computer:

#!/bin/bash

# Script to calculate daily active time from ~/.activity_log
# Active time is defined as periods when idle time is less than 300 seconds
# This script identifies contiguous active sessions and sums their durations
# Get today's date in YYYY-MM-DD format
TODAY=$(date "+%Y-%m-%d")

# Define activity log path
ACTIVITY_LOG=~/.activity_log

# Define idle threshold in seconds (user is considered active if idle time is below this)
IDLE_THRESHOLD=300

# Enable or disable debug output
DEBUG=true

# Function to output debug information
debug_log() {
    if [ "$DEBUG" = true ]; then
        echo "[DEBUG] $1"
    fi
}

# Check if the activity log exists
if [ ! -f "$ACTIVITY_LOG" ]; then
    echo "Error: Activity log ($ACTIVITY_LOG) not found."
    exit 1
fi

debug_log "Processing activity log for $TODAY"

# Create a temporary file with today's entries to avoid pipe issues
TMP_FILE=$(mktemp)
grep "$TODAY" "$ACTIVITY_LOG" > "$TMP_FILE"

# Check if there are any entries for today
if [ ! -s "$TMP_FILE" ]; then
    echo "No activity logged for today ($TODAY)."
    rm "$TMP_FILE"
    exit 0
fi

# Initialize variables
FIRST_ACTIVE_TIMESTAMP=""
LAST_ACTIVE_TIMESTAMP=""
TOTAL_ACTIVE_ENTRIES=0
TOTAL_ACTIVE_SECONDS=0
ACTIVE_SESSIONS=0

# Store all active timestamps for processing
declare -a ACTIVE_TIMESTAMPS
declare -a UNIX_ACTIVE_TIMESTAMPS
debug_log "Parsing log entries..."

# Process each line in today's log entries
while read -r line; do
    # Extract timestamp (assuming format like "2023-11-08 14:30:45")
    TIMESTAMP=$(echo "$line" | grep -o '[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\} [0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}')
    
    # Extract idle time (assuming format like "Idle: 123 sec")
    IDLE_TIME=$(echo "$line" | grep -o 'Idle: [0-9]\+ sec' | grep -o '[0-9]\+')
    
    # Skip if we couldn't parse the line properly
    if [ -z "$TIMESTAMP" ] || [ -z "$IDLE_TIME" ]; then
        debug_log "Skipping malformed line: $line"
        continue
    fi
    
    # Convert timestamp to Unix time for calculations
    UNIX_TIMESTAMP=$(date -d "$TIMESTAMP" "+%s")
    
    # Check if user was active during this entry
    if [ "$IDLE_TIME" -lt "$IDLE_THRESHOLD" ]; then
        # User was active
        TOTAL_ACTIVE_ENTRIES=$((TOTAL_ACTIVE_ENTRIES + 1))
        
        # Store timestamp for processing and debugging
        ACTIVE_TIMESTAMPS+=("$TIMESTAMP (Idle: ${IDLE_TIME}s)")
        UNIX_ACTIVE_TIMESTAMPS+=($UNIX_TIMESTAMP)
    fi
done < "$TMP_FILE"

# Clean up temporary file
rm "$TMP_FILE"

debug_log "Total active entries: $TOTAL_ACTIVE_ENTRIES"

# Calculate total active time based on contiguous sessions
if [ ${#UNIX_ACTIVE_TIMESTAMPS[@]} -gt 0 ]; then
    # Sort timestamps to ensure chronological order
    IFS=$'\n' SORTED_TIMESTAMPS=($(sort -n <<<"${UNIX_ACTIVE_TIMESTAMPS[*]}"))
    unset IFS
    
    debug_log "Processing ${#SORTED_TIMESTAMPS[@]} active timestamps to identify sessions..."
    
    # Log all active timestamps for debugging
    if [ "$DEBUG" = true ]; then
        echo "[DEBUG] All active timestamps:"
        for i in "${!ACTIVE_TIMESTAMPS[@]}"; do
            echo "  - ${ACTIVE_TIMESTAMPS[$i]}"
        done
    fi
    
    # Initialize session tracking
    SESSION_START=${SORTED_TIMESTAMPS[0]}
    PREV_TIMESTAMP=${SORTED_TIMESTAMPS[0]}
    FIRST_ACTIVE_TIMESTAMP=$(date -d @${SORTED_TIMESTAMPS[0]} "+%Y-%m-%d %H:%M:%S")
    LAST_ACTIVE_TIMESTAMP=$(date -d @${SORTED_TIMESTAMPS[-1]} "+%Y-%m-%d %H:%M:%S")
    ACTIVE_SESSIONS=1
    
    debug_log "Started session #1 at $(date -d @$SESSION_START "+%Y-%m-%d %H:%M:%S")"
    
    # Process each timestamp to identify sessions
    for ((i=1; i<${#SORTED_TIMESTAMPS[@]}; i++)); do
        CURRENT_TIMESTAMP=${SORTED_TIMESTAMPS[$i]}
        TIME_DIFF=$((CURRENT_TIMESTAMP - PREV_TIMESTAMP))
        
        # If gap is larger than threshold, end current session and start new one
        if [ $TIME_DIFF -gt $IDLE_THRESHOLD ]; then
            # Calculate duration of the completed session
            SESSION_DURATION=$((PREV_TIMESTAMP - SESSION_START))
            TOTAL_ACTIVE_SECONDS=$((TOTAL_ACTIVE_SECONDS + SESSION_DURATION))
            
            debug_log "Ended session #$ACTIVE_SESSIONS at $(date -d @$PREV_TIMESTAMP "+%Y-%m-%d %H:%M:%S") (duration: $(date -u -d @$SESSION_DURATION "+%H:%M:%S"))"
            
            # Start a new session
            SESSION_START=$CURRENT_TIMESTAMP
            ACTIVE_SESSIONS=$((ACTIVE_SESSIONS + 1))
            
            debug_log "Started session #$ACTIVE_SESSIONS at $(date -d @$CURRENT_TIMESTAMP "+%Y-%m-%d %H:%M:%S")"
        fi
        
        PREV_TIMESTAMP=$CURRENT_TIMESTAMP
    done
    
    # Calculate duration of the last session
    SESSION_DURATION=$((PREV_TIMESTAMP - SESSION_START))
    TOTAL_ACTIVE_SECONDS=$((TOTAL_ACTIVE_SECONDS + SESSION_DURATION))
    
    debug_log "Ended session #$ACTIVE_SESSIONS at $(date -d @$PREV_TIMESTAMP "+%Y-%m-%d %H:%M:%S") (duration: $(date -u -d @$SESSION_DURATION "+%H:%M:%S"))"
    debug_log "Total active sessions: $ACTIVE_SESSIONS"
    debug_log "First active timestamp: $FIRST_ACTIVE_TIMESTAMP"
    debug_log "Last active timestamp: $LAST_ACTIVE_TIMESTAMP"
    debug_log "Total active time in seconds: $TOTAL_ACTIVE_SECONDS"
else
    debug_log "No active periods found based on idle threshold"
    TOTAL_ACTIVE_SECONDS=0
    FIRST_ACTIVE_TIMESTAMP=""
    LAST_ACTIVE_TIMESTAMP=""
fi

# Convert seconds to hours and minutes
HOURS=$((TOTAL_ACTIVE_SECONDS / 3600))
MINUTES=$(( (TOTAL_ACTIVE_SECONDS % 3600) / 60 ))

# Print summary
echo "=== Activity Summary for $TODAY ==="
echo "Total active time today: $HOURS hours $MINUTES minutes"
echo "Based on idle threshold of $IDLE_THRESHOLD seconds"
if [ -n "$FIRST_ACTIVE_TIMESTAMP" ] && [ -n "$LAST_ACTIVE_TIMESTAMP" ]; then
    echo "Activity span: $FIRST_ACTIVE_TIMESTAMP to $LAST_ACTIVE_TIMESTAMP"
fi
echo "Total entries counted as active: $TOTAL_ACTIVE_ENTRIES"
echo "Contiguous active sessions: $ACTIVE_SESSIONS"

The script works by:

  1. Filtering out entries from today’s date
  2. Considering me “active” only when my idle time is less than 300 seconds (5 minutes)
  3. Finding the first and last active timestamps of the day
  4. Calculating the total span between these timestamps
  5. Converting this span to hours and minutes

This approach means I’m counting the entire span from when I first start using my computer until the last time I use it, which works well for my purposes.

Posting to Beeminder

The final piece is post_active_time_to_beeminder.sh, which takes the calculated active time and sends it to Beeminder:

#!/bin/bash
#
# post_active_time_to_beeminder.sh
# 
# Description: This script parses active time from daily_active_time.sh 
# and submits it to Beeminder. It extracts the total hours and minutes,
# converts them to a decimal format, and posts to the specified Beeminder goal.

# Function to log error messages and exit
log_error() {
    echo "ERROR: $1" >&2
    exit 1
}

# Function to log informational messages
log_info() {
    echo "INFO: $1"
}

# Get Beeminder goal name from command-line argument or use default
GOAL_NAME="${1:-bizsys}"
log_info "Using Beeminder goal: $GOAL_NAME"

# Check for auth token
if [ -z "$BM_AUTH_TOKEN" ]; then
    log_error "Beeminder authentication token not found. Please set the BM_AUTH_TOKEN environment variable."
fi

# Check for Beeminder username
if [ -z "$BM_USERNAME" ]; then
    log_error "Beeminder username not found. Please set the BM_USERNAME environment variable."
fi

# Execute daily_active_time.sh and capture output
if ! OUTPUT=$(~/.scripts/daily_active_time.sh); then
    log_error "Failed to execute daily_active_time.sh"
fi

# Check if output is empty
if [ -z "$OUTPUT" ]; then
    log_error "No output received from daily_active_time.sh"
fi

log_info "Successfully captured output from daily_active_time.sh"

# Parse the output to find total hours and minutes
# Expected format: "Total active time today: X hours Y minutes"
HOURS=$(echo "$OUTPUT" | grep -oP "Total active time today: \K[0-9]+ (?=hours)" || echo "0")
MINUTES=$(echo "$OUTPUT" | grep -oP "hours \K[0-9]+ (?=minutes)" || echo "0")

# Trim whitespace
HOURS=$(echo "$HOURS" | tr -d ' ')
MINUTES=$(echo "$MINUTES" | tr -d ' ')

# Validate that we got numeric values
if ! [[ "$HOURS" =~ ^[0-9]+$ ]]; then
    log_error "Failed to parse hours from the output: $OUTPUT"
fi

if ! [[ "$MINUTES" =~ ^[0-9]+$ ]]; then
    log_error "Failed to parse minutes from the output: $OUTPUT"
fi

log_info "Parsed active time: $HOURS hours and $MINUTES minutes"

# Convert to decimal format
if ! DECIMAL_VALUE=$(echo "scale=2; $HOURS + $MINUTES/60" | bc); then
    log_error "Failed to convert hours and minutes to decimal format"
fi

log_info "Converted to decimal format: $DECIMAL_VALUE hours"

# Prepare the Beeminder API request
API_URL="https://www.beeminder.com/api/v1/users/$BM_USERNAME/goals/$GOAL_NAME/datapoints.json"
COMMENT="Automated daily active time submission"
TIMESTAMP=$(date +%s)
REQUEST_ID=$(date +"%Y%m%d")

log_info "Preparing to send data to Beeminder: $DECIMAL_VALUE hours"

# Send the request to Beeminder API using curl
RESPONSE=$(curl -s -w "%{http_code}" -X POST \
    -d "auth_token=$BM_AUTH_TOKEN" \
    -d "value=$DECIMAL_VALUE" \
    -d "comment=$COMMENT" \
    -d "timestamp=$TIMESTAMP" \
    -d "requestid=$REQUEST_ID" \
    "$API_URL")

# Extract HTTP status code
HTTP_STATUS="${RESPONSE: -3}"
RESPONSE_BODY="${RESPONSE:0:${#RESPONSE}-3}"

# Check if request was successful
if [ "$HTTP_STATUS" -eq 200 ] || [ "$HTTP_STATUS" -eq 201 ]; then
    log_info "Successfully posted $DECIMAL_VALUE hours to Beeminder goal '$GOAL_NAME'"
else
    log_error "Failed to post to Beeminder. HTTP Status: $HTTP_STATUS, Response: $RESPONSE_BODY"
fi

exit 0

This script:

  1. Runs daily_active_time.sh and captures its output
  2. Parses the hours and minutes from the output
  3. Converts them to a decimal format (e.g., 7 hours and 30 minutes becomes 7.5)
  4. Sends a POST request to the Beeminder API with the value

The script requires two environment variables to be set in ~/.bashrc:

  • BM_USERNAME: Your Beeminder username
  • BM_AUTH_TOKEN: Your Beeminder API authentication token

It also includes robust error handling to log issues if anything goes wrong during the process.

Scheduling with Systemd and Cron

To automate everything, I use:

  1. Systemd Timer: Runs activity_monitor.sh every minute to ensure near real-time logging

    # /etc/systemd/system/activity-monitor.service
    [Unit]
    Description=Monitor active window and idle time
    
    [Service]
    Type=oneshot
    ExecStart=/home/username/.scripts/activity_monitor.sh
    
    [Install]
    WantedBy=multi-user.target
    
    # /etc/systemd/system/activity-monitor.timer
    [Unit]
    Description=Run activity monitor every minute
    
    [Timer]
    OnBootSec=1min
    OnUnitActiveSec=1min
    
    [Install]
    WantedBy=timers.target
    
  2. Cron Job: Triggers post_active_time_to_beeminder.sh hourly to keep Beeminder updated

    # Add to crontab with: crontab -e
    0 * * * * . /home/username/.bashrc && /home/username/.scripts/post_active_time_to_beeminder.sh
    

Managing Log Growth with Automatic Log Rotation

One challenge with any logging system is managing file size. Without proper maintenance, my ~/.activity_log would grow indefinitely, potentially consuming significant disk space over time. To prevent this, I implemented a simple log rotation system using logrotate, a standard Linux utility designed specifically for this purpose.

Setting Up Logrotate

I created a custom logrotate configuration file:

# /etc/logrotate.d/activity_log
/home/narthur/.activity_log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
    size 10M
}

This configuration:

  • Rotates the log file weekly or when it reaches 10MB in size (whichever comes first)
  • Keeps 4 rotated log files before deleting older ones
  • Compresses older logs to save space
  • Skips rotation if the log file is missing (missingok)
  • Doesn’t rotate empty log files (notifempty)

This approach strikes a good balance between preserving historical data and managing disk space. The size-based trigger (10MB) ensures that even during periods of heavy computer usage, the log file won’t grow excessively large.

Accessing Historical Data

With log rotation in place, I can still access historical data when needed:

# View a previous activity log
zcat ~/.activity_log.1.gz | grep "App: firefox"

This makes it possible to perform retrospective analysis across multiple weeks while keeping the current log file manageable.

By implementing this log rotation strategy, I ensure that my activity tracking system remains sustainable over the long term without manual intervention or risk of filling up my disk space. The configuration is minimal but effective, which aligns with the overall philosophy of this tracking system: simple, reliable automation.

Benefits and Future Improvements

This system has several benefits:

  • Automatic tracking: Once set up, it requires zero manual intervention
  • Accurate measurement: It only counts time when I’m actively using my computer
  • Accountability: Beeminder helps me stay aware of my computer usage patterns

Some potential improvements I’m considering:

  • Adding application-specific tracking to see which programs I use most
  • Creating weekly reports with visualizations of my usage patterns
  • Implementing categorization to distinguish between work and leisure activities
  • Adding a dashboard to view my activity in real-time

Key Takeaways

This project demonstrates how a few simple bash scripts can be combined to create a powerful self-tracking system. The modular design—with separate scripts for data collection, analysis, and reporting—makes it easy to maintain and extend.

The beauty of this approach is its simplicity and flexibility. You could easily adapt it to track different metrics or post to different services beyond Beeminder.

If you’re interested in tracking your own computer usage, feel free to use these scripts as a starting point.

Happy tracking!

3 Likes

I don’t remember if we’ve talked about this before, but have you looked at https://activitywatch.net/ before? It’s open source, has a modular architecture, and I’ve reused parts in different projects before.

3 Likes

I just extended this setup to run before Debian goes to sleep.

/etc/systemd/system/pre-sleep.service:

   [Unit]
   Description=Run script before system sleeps
   Before=sleep.target
   StopWhenUnneeded=true

   [Service]
   Type=oneshot
   ExecStart=/home/username/.scripts/pre-sleep-script.sh

   [Install]
   WantedBy=sleep.target

/home/username/.scripts/pre-sleep-script.sh:

#!/bin/bash

source /home/username/.bashrc
/home/username/.scripts/post_active_time_to_beeminder.sh

Setup commands:

chmod +x /home/username/.scripts/pre-sleep-script.sh
sudo systemctl enable pre-sleep.service
1 Like