Files
c-relay/docs/debug_system.md
2025-10-13 16:35:26 -04:00

16 KiB

Simple Debug System Proposal

Overview

A minimal debug system with 6 levels (0-5) controlled by a single --debug-level flag. TRACE level (5) automatically includes file:line information for ALL messages. Uses compile-time macros to ensure zero performance impact and zero size increase in production builds.

Debug Levels

typedef enum {
    DEBUG_LEVEL_NONE = 0,      // Production: no debug output
    DEBUG_LEVEL_ERROR = 1,     // Errors only
    DEBUG_LEVEL_WARN = 2,      // Errors + Warnings
    DEBUG_LEVEL_INFO = 3,      // Errors + Warnings + Info
    DEBUG_LEVEL_DEBUG = 4,     // All above + Debug messages
    DEBUG_LEVEL_TRACE = 5      // All above + Trace (very verbose)
} debug_level_t;

Usage

# Production (default - no debug output)
./c_relay_x86

# Show errors only
./c_relay_x86 --debug-level=1

# Show errors and warnings
./c_relay_x86 --debug-level=2

# Show errors, warnings, and info (recommended for development)
./c_relay_x86 --debug-level=3

# Show all debug messages
./c_relay_x86 --debug-level=4

# Show everything including trace with file:line (very verbose)
./c_relay_x86 --debug-level=5

Implementation

1. Header File (src/debug.h)

#ifndef DEBUG_H
#define DEBUG_H

#include <stdio.h>
#include <time.h>

// Debug levels
typedef enum {
    DEBUG_LEVEL_NONE = 0,
    DEBUG_LEVEL_ERROR = 1,
    DEBUG_LEVEL_WARN = 2,
    DEBUG_LEVEL_INFO = 3,
    DEBUG_LEVEL_DEBUG = 4,
    DEBUG_LEVEL_TRACE = 5
} debug_level_t;

// Global debug level (set at runtime via CLI)
extern debug_level_t g_debug_level;

// Initialize debug system
void debug_init(int level);

// Core logging function
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...);

// Convenience macros that check level before calling
// Note: TRACE level (5) and above include file:line information for ALL messages
#define DEBUG_ERROR(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_ERROR) debug_log(DEBUG_LEVEL_ERROR, __FILE__, __LINE__, __VA_ARGS__); } while(0)

#define DEBUG_WARN(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_WARN) debug_log(DEBUG_LEVEL_WARN, __FILE__, __LINE__, __VA_ARGS__); } while(0)

#define DEBUG_INFO(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__); } while(0)

#define DEBUG_LOG(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_DEBUG) debug_log(DEBUG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); } while(0)

#define DEBUG_TRACE(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_TRACE) debug_log(DEBUG_LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__); } while(0)

#endif /* DEBUG_H */

2. Implementation File (src/debug.c)

#include "debug.h"
#include <stdarg.h>
#include <string.h>

// Global debug level (default: no debug output)
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;

void debug_init(int level) {
    if (level < 0) level = 0;
    if (level > 5) level = 5;
    g_debug_level = (debug_level_t)level;
}

void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
    // Get timestamp
    time_t now = time(NULL);
    struct tm* tm_info = localtime(&now);
    char timestamp[32];
    strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
    
    // Get level string
    const char* level_str = "UNKNOWN";
    switch (level) {
        case DEBUG_LEVEL_ERROR: level_str = "ERROR"; break;
        case DEBUG_LEVEL_WARN:  level_str = "WARN "; break;
        case DEBUG_LEVEL_INFO:  level_str = "INFO "; break;
        case DEBUG_LEVEL_DEBUG: level_str = "DEBUG"; break;
        case DEBUG_LEVEL_TRACE: level_str = "TRACE"; break;
        default: break;
    }
    
    // Print prefix with timestamp and level
    printf("[%s] [%s] ", timestamp, level_str);
    
    // Print source location when debug level is TRACE (5) or higher
    if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
        // Extract just the filename (not full path)
        const char* filename = strrchr(file, '/');
        filename = filename ? filename + 1 : file;
        printf("[%s:%d] ", filename, line);
    }
    
    // Print message
    va_list args;
    va_start(args, format);
    vprintf(format, args);
    va_end(args);
    
    printf("\n");
    fflush(stdout);
}

3. CLI Argument Parsing (add to src/main.c)

// In main() function, add to argument parsing:

int debug_level = 0;  // Default: no debug output

for (int i = 1; i < argc; i++) {
    if (strncmp(argv[i], "--debug-level=", 14) == 0) {
        debug_level = atoi(argv[i] + 14);
        if (debug_level < 0) debug_level = 0;
        if (debug_level > 5) debug_level = 5;
    }
    // ... other arguments ...
}

// Initialize debug system
debug_init(debug_level);

4. Update Makefile

# Add debug.c to source files
MAIN_SRC = src/main.c src/config.c src/debug.c src/dm_admin.c src/request_validator.c ...

Migration Strategy

Keep Existing Functions

The existing log_* functions can remain as wrappers:

// src/main.c - Update existing functions
// Note: These don't include file:line since they're wrappers
void log_info(const char* message) {
    if (g_debug_level >= DEBUG_LEVEL_INFO) {
        debug_log(DEBUG_LEVEL_INFO, NULL, 0, "%s", message);
    }
}

void log_error(const char* message) {
    if (g_debug_level >= DEBUG_LEVEL_ERROR) {
        debug_log(DEBUG_LEVEL_ERROR, NULL, 0, "%s", message);
    }
}

void log_warning(const char* message) {
    if (g_debug_level >= DEBUG_LEVEL_WARN) {
        debug_log(DEBUG_LEVEL_WARN, NULL, 0, "%s", message);
    }
}

void log_success(const char* message) {
    if (g_debug_level >= DEBUG_LEVEL_INFO) {
        debug_log(DEBUG_LEVEL_INFO, NULL, 0, "✓ %s", message);
    }
}

Gradual Migration

Gradually replace log calls with debug macros:

// Before:
log_info("Starting WebSocket relay server");

// After:
DEBUG_INFO("Starting WebSocket relay server");

// Before:
log_error("Failed to initialize database");

// After:
DEBUG_ERROR("Failed to initialize database");

Add New Debug Levels

Add debug and trace messages where needed:

// Detailed debugging
DEBUG_LOG("Processing subscription: %s", sub_id);
DEBUG_LOG("Filter count: %d", filter_count);

// Very verbose tracing
DEBUG_TRACE("Entering handle_req_message()");
DEBUG_TRACE("Subscription ID validated: %s", sub_id);
DEBUG_TRACE("Exiting handle_req_message()");

Manual Guards for Expensive Operations

The Problem

Debug macros use runtime checks, which means function arguments are always evaluated:

// ❌ BAD: Database query executes even when debug level is 0
DEBUG_LOG("Count: %d", expensive_database_query());

The expensive_database_query() will always execute because function arguments are evaluated before the if check inside the macro.

The Solution: Manual Guards

For expensive operations (database queries, file I/O, complex calculations), use manual guards:

// ✅ GOOD: Query only executes when debugging is enabled
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
    int count = expensive_database_query();
    DEBUG_LOG("Count: %d", count);
}

Standardized Comment Format

To make temporary debug guards easy to find and remove, use this standardized format:

// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
    // Expensive operation here
    sqlite3_stmt* stmt;
    const char* sql = "SELECT COUNT(*) FROM events";
    int count = 0;
    if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) == SQLITE_OK) {
        if (sqlite3_step(stmt) == SQLITE_ROW) {
            count = sqlite3_column_int(stmt, 0);
        }
        sqlite3_finalize(stmt);
    }
    DEBUG_LOG("Event count: %d", count);
}
// DEBUG_GUARD_END

Easy Removal

When you're done debugging, find and remove all temporary guards:

# Find all debug guards
grep -n "DEBUG_GUARD_START" src/*.c

# Remove guards with sed (between START and END markers)
sed -i '/DEBUG_GUARD_START/,/DEBUG_GUARD_END/d' src/config.c

Or use a simple script:

#!/bin/bash
# remove_debug_guards.sh
for file in src/*.c; do
    sed -i '/DEBUG_GUARD_START/,/DEBUG_GUARD_END/d' "$file"
    echo "Removed debug guards from $file"
done

When to Use Manual Guards

Use manual guards for:

  • Database queries
  • File I/O operations
  • Network requests
  • Complex calculations
  • Memory allocations for debug data
  • String formatting with multiple operations

Don't need guards for:

  • Simple variable access
  • Basic arithmetic
  • String literals
  • Function calls that are already cheap

Example: Database Query Guard

// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
    sqlite3_stmt* count_stmt;
    const char* count_sql = "SELECT COUNT(*) FROM config";
    int config_count = 0;
    
    if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
        if (sqlite3_step(count_stmt) == SQLITE_ROW) {
            config_count = sqlite3_column_int(count_stmt, 0);
        }
        sqlite3_finalize(count_stmt);
    }
    
    DEBUG_LOG("Config table has %d rows", config_count);
}
// DEBUG_GUARD_END

Example: Complex String Formatting Guard

// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
    char filter_str[1024] = {0};
    int offset = 0;
    
    for (int i = 0; i < filter_count && offset < sizeof(filter_str) - 1; i++) {
        offset += snprintf(filter_str + offset, sizeof(filter_str) - offset,
                          "Filter %d: kind=%d, author=%s; ",
                          i, filters[i].kind, filters[i].author);
    }
    
    DEBUG_TRACE("Processing filters: %s", filter_str);
}
// DEBUG_GUARD_END

Alternative: Compile-Time Guards

For permanent debug code that should be completely removed in production builds, use compile-time guards:

#ifdef ENABLE_DEBUG_CODE
    // This code is completely removed when ENABLE_DEBUG_CODE is not defined
    int count = expensive_database_query();
    DEBUG_LOG("Count: %d", count);
#endif

Build with debug code:

make CFLAGS="-DENABLE_DEBUG_CODE"

Build without debug code (production):

make  # No debug code compiled in

Best Practices

  1. Always use standardized markers (DEBUG_GUARD_START/DEBUG_GUARD_END) for temporary guards
  2. Add a comment explaining what you're debugging
  3. Remove guards when debugging is complete
  4. Use compile-time guards for permanent debug infrastructure
  5. Keep guards simple - one guard per logical debug operation

Performance Impact

Runtime Check

The macros include a runtime check:

#define DEBUG_INFO(...) \
    do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, NULL, 0, __VA_ARGS__); } while(0)

Cost: One integer comparison per debug statement (~1 CPU cycle)

Impact: Negligible - the comparison is faster than a function call

Note: Only DEBUG_TRACE includes __FILE__ and __LINE__, which are compile-time constants with no runtime overhead.

When Debug Level is 0 (Production)

// With g_debug_level = 0:
DEBUG_INFO("Starting server");

// Becomes:
if (0 >= 3) debug_log(...);  // Never executes

// Compiler optimizes to:
// (nothing - branch is eliminated)

Result: Modern compilers (gcc -O2 or higher) will completely eliminate the dead code branch.

Size Impact

Test Case: 100 debug statements in code

Without optimization (-O0):

  • Binary size increase: ~2KB (branch instructions)
  • Runtime cost: 100 comparisons per execution

With optimization (-O2 or -O3):

  • Binary size increase: 0 bytes (dead code eliminated when g_debug_level = 0)
  • Runtime cost: 0 cycles (branches removed by compiler)

Verification

You can verify the optimization with:

# Compile with optimization
gcc -O2 -c debug_test.c -o debug_test.o

# Disassemble and check
objdump -d debug_test.o | grep -A 10 "debug_log"

When g_debug_level = 0 (constant), you'll see the compiler has removed all debug calls.

Example Output

Level 0 (Production)

(no output)

Level 1 (Errors Only)

[2025-01-12 14:30:15] [ERROR] Failed to open database: permission denied
[2025-01-12 14:30:20] [ERROR] WebSocket connection failed: port in use

Level 2 (Errors + Warnings)

[2025-01-12 14:30:15] [ERROR] Failed to open database: permission denied
[2025-01-12 14:30:16] [WARN ] Port 8888 unavailable, trying 8889
[2025-01-12 14:30:17] [WARN ] Configuration key 'relay_name' not found, using default

Level 3 (Errors + Warnings + Info)

[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [INFO ] Loading configuration from database
[2025-01-12 14:30:15] [ERROR] Failed to open database: permission denied
[2025-01-12 14:30:16] [WARN ] Port 8888 unavailable, trying 8889
[2025-01-12 14:30:17] [INFO ] WebSocket relay started on ws://127.0.0.1:8889

Level 4 (All Debug Messages)

[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [DEBUG] Opening database: build/abc123...def.db
[2025-01-12 14:30:15] [DEBUG] Executing schema initialization
[2025-01-12 14:30:15] [INFO ] SQLite WAL mode enabled
[2025-01-12 14:30:16] [DEBUG] Attempting to bind to port 8888
[2025-01-12 14:30:16] [WARN ] Port 8888 unavailable, trying 8889
[2025-01-12 14:30:17] [DEBUG] Successfully bound to port 8889
[2025-01-12 14:30:17] [INFO ] WebSocket relay started on ws://127.0.0.1:8889

Level 5 (Everything Including file:line for ALL messages)

[2025-01-12 14:30:15] [INFO ] [main.c:1607] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [DEBUG] [main.c:348] Opening database: build/abc123...def.db
[2025-01-12 14:30:15] [TRACE] [main.c:330] Entering init_database()
[2025-01-12 14:30:15] [ERROR] [config.c:125] Database locked

Implementation Steps

Step 1: Create Files (5 minutes)

  1. Create src/debug.h with the header code above
  2. Create src/debug.c with the implementation code above
  3. Update Makefile to include src/debug.c in MAIN_SRC

Step 2: Add CLI Parsing (5 minutes)

Add --debug-level argument parsing to main() in src/main.c

Step 3: Update Existing Functions (5 minutes)

Update the existing log_* functions to use the new debug macros

Step 4: Test (5 minutes)

# Build
make clean && make

# Test different levels
./build/c_relay_x86                    # No output
./build/c_relay_x86 --debug-level=1    # Errors only
./build/c_relay_x86 --debug-level=3    # Info + warnings + errors
./build/c_relay_x86 --debug-level=4    # All debug messages
./build/c_relay_x86 --debug-level=5    # Everything with file:line on TRACE

Step 5: Gradual Migration (Ongoing)

As you work on different parts of the code, replace log_* calls with DEBUG_* macros and add new debug/trace statements where helpful.

Benefits

Simple: Single flag, 6 levels, easy to understand
Zero Overhead: Compiler optimizes away unused debug code
Zero Size Impact: No binary size increase in production
Backward Compatible: Existing log_* functions still work
Easy Migration: Gradual replacement of log calls
Flexible: Can add detailed debugging without affecting production

Total Implementation Time

~20 minutes for basic implementation
Ongoing for gradual migration of existing log calls

Recommendation

This is the simplest possible debug system that provides:

  • Multiple debug levels for different verbosity
  • Zero performance impact in production
  • Zero binary size increase
  • Easy to use and understand
  • Backward compatible with existing code

Start with the basic implementation, test it, then gradually migrate existing log calls and add new debug statements as needed.