DEV Community

Cover image for Creating a Robust Logging System in C
Vitor Lobo
Vitor Lobo

Posted on

Creating a Robust Logging System in C

Creating robust software involves making deliberate design choices that simplify code maintenance and extend functionality. One such example is implementing logging functionality in a C application. Logging is not just about printing error messages; it's about building a structured system that supports debugging, analysis, and even cross-platform compatibility.

In this article, we’ll explore how to build a logging system step by step using design patterns and best practices, inspired by real-world scenarios. By the end, you'll have a solid understanding of creating a flexible and extensible logging system in C.

Table of Contents

  1. The Need for Logging
  2. Organizing Files for Logging
  3. Creating a Central Logging Function
  4. Implementing Software-Module Filters
  5. Adding Conditional Logging
  6. Managing Resources Properly
  7. Ensuring Thread Safety
  8. External and Dynamic Configuration
  9. Custom Log Formatting
  10. Internal Error Handling
  11. Performance and Efficiency
  12. Security Best Practices
  13. Integrating with Logging Tools
  14. Testing and Validation
  15. Cross-Platform File Logging
  16. Wrapping It All Up
  17. Extra

The Need for Logging

Imagine maintaining a software system deployed at a remote site. Whenever an issue arises, you must physically travel to debug the problem. This setup quickly becomes impractical as deployments scale geographically. Logging can save the day.

Logging provides a detailed account of the system’s internal state at critical points during execution. By examining log files, developers can diagnose and resolve issues without reproducing them directly. This is especially useful for sporadic errors that are difficult to recreate in a controlled environment.

The value of logging becomes even more apparent in multithreaded applications, where errors may depend on timing and race conditions. Debugging these issues without logs would require significant effort and specialized tools, which may not always be available. Logs offer a snapshot of what happened, helping pinpoint the root cause.

However, logging is not just a simple feature—it’s a system. A poorly implemented logging mechanism can lead to performance issues, security vulnerabilities, and unmaintainable code. Therefore, following structured approaches and patterns is crucial when designing a logging system.

Organizing Files for Logging

Proper file organization is essential to keep your codebase maintainable as it grows. Logging, being a distinct functionality, should be isolated into its own module, making it easy to locate and modify without affecting unrelated parts of the code.

Header file (logger.h):

#ifndef LOGGER_H
#define LOGGER_H

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

// Function prototypes
void log_message(const char* text);

#endif // LOGGER_H
Enter fullscreen mode Exit fullscreen mode

Implementation file (logger.c):

#include "logger.h"

void log_message(const char* text) {
    if (!text) {
        fprintf(stderr, "Invalid log message\n");
        return;
    }
    time_t now = time(NULL);
    printf("[%s] %s\n", ctime(&now), text);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    log_message("Application started");
    log_message("Performing operation...");
    log_message("Operation completed.");
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

To compile and run the example, use the following commands in your terminal:

gcc -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

Expected Output:

[Mon Sep 27 14:00:00 2021
] Application started
[Mon Sep 27 14:00:00 2021
] Performing operation...
[Mon Sep 27 14:00:00 2021
] Operation completed.
Enter fullscreen mode Exit fullscreen mode

The first step is to create a dedicated directory for logging. This directory should house all related implementation files. For example, logger.c can contain the core logic of your logging system, while logger_test.c can hold unit tests. Keeping related files together improves both clarity and collaboration within a development team.

Additionally, the logging interface should be exposed via a header file, such as logger.h, placed in an appropriate directory, such as include/ or the same directory as your source files. This ensures that other modules needing logging capabilities can access it easily. Keeping the header file separate from the implementation file also supports encapsulation, hiding implementation details from users of the logging API.

Finally, adopting a consistent naming convention for your directories and files further enhances maintainability. For example, using logger.h and logger.c makes it clear that these files belong to the logging module. Avoid mixing unrelated code into the logging module, as this defeats the purpose of modularization.

Creating a Central Logging Function

At the heart of any logging system lies a central function that handles the core operation: recording log messages. This function should be designed with simplicity and extensibility in mind to support future enhancements without requiring major changes.

Implementation (logger.c):

#include "logger.h"
#include <stdio.h>
#include <time.h>
#include <assert.h>

#define BUFFER_SIZE 256
static_assert(BUFFER_SIZE >= 64, "Buffer size is too small");

void log_message(const char* text) {
    char buffer[BUFFER_SIZE];
    time_t now = time(NULL);

    if (!text) {
        fprintf(stderr, "Error: Null message passed to log_message\n");
        return;
    }

    snprintf(buffer, BUFFER_SIZE, "[%s] %s", ctime(&now), text);
    printf("%s", buffer);
}
Enter fullscreen mode Exit fullscreen mode

Note: The use of static_assert requires C11 or later. Ensure your compiler supports this standard.

A basic logging function can start by printing messages to the standard output. Adding a timestamp to each log entry improves its usefulness by providing temporal context. For example, logs can help identify when a particular error occurred or how events unfolded over time.

To keep the logging module stateless, avoid retaining any internal state between function calls. This design choice simplifies the implementation and ensures that the module works seamlessly in multithreaded environments. Stateless modules are also easier to test and debug since their behavior doesn’t depend on prior interactions.

Consider error handling when designing the logging function. For example, what happens if a NULL pointer is passed as a log message? Following the "Samurai Principle," the function should either handle this gracefully or fail immediately, making debugging easier.

Implementing Software-Module Filters

As applications grow in complexity, their logging output can become overwhelming. Without filters, logs from unrelated modules may flood the console, making it difficult to focus on relevant information. Implementing filters ensures that only the desired logs are recorded.

To achieve this, introduce a mechanism to track enabled modules. This could be as simple as a global list or as sophisticated as a dynamically allocated hash table. The list stores module names, and only logs from these modules are processed.

Filtering is implemented by adding a module parameter to the logging function. Before writing a log, the function checks if the module is enabled. If not, it skips the log entry. This approach keeps the logging output concise and focused on the areas of interest. Here's an example implementation of filtering:

Header File (logger.h):

#ifndef LOGGER_H
#define LOGGER_H

#include <stdbool.h>

void enable_module(const char* module);
void disable_module(const char* module);
void log_message(const char* module, const char* text);

#endif // LOGGER_H
Enter fullscreen mode Exit fullscreen mode

Implementation File (logger.c):

#include "logger.h"
#include <stdio.h>
#include <string.h>

#define MAX_MODULES 10
#define MODULE_NAME_LENGTH 20

static char enabled_modules[MAX_MODULES][MODULE_NAME_LENGTH];

void enable_module(const char* module) {
    for (int i = 0; i < MAX_MODULES; i++) {
        if (enabled_modules[i][0] == '\0') {
            strncpy(enabled_modules[i], module, MODULE_NAME_LENGTH - 1);
            enabled_modules[i][MODULE_NAME_LENGTH - 1] = '\0';
            break;
        }
    }
}

void disable_module(const char* module) {
    for (int i = 0; i < MAX_MODULES; i++) {
        if (strcmp(enabled_modules[i], module) == 0) {
            enabled_modules[i][0] = '\0';
            break;
        }
    }
}

static int is_module_enabled(const char* module) {
    for (int i = 0; i < MAX_MODULES; i++) {
        if (strcmp(enabled_modules[i], module) == 0) {
            return 1;
        }
    }
    return 0;
}

void log_message(const char* module, const char* text) {
    if (!is_module_enabled(module)) {
        return;
    }
    time_t now = time(NULL);
    printf("[%s][%s] %s\n", ctime(&now), module, text);
}
Enter fullscreen mode Exit fullscreen mode

This implementation strikes a balance between simplicity and functionality, providing a solid starting point for module-specific logging.

Adding Conditional Logging

Conditional logging is essential for creating flexible systems that adapt to different environments or runtime conditions. For instance, during development, you might need verbose debug logs to trace application behavior. In production, you’d likely prefer to log only warnings and errors to minimize performance overhead.

One way to implement this is by introducing log levels. Common levels include DEBUG, INFO, WARNING, and ERROR. The logging function can take an additional parameter for the log level, and logs are recorded only if their level meets or exceeds the current threshold. This approach ensures that irrelevant messages are filtered out, keeping the logs concise and useful.

To make this configurable, you can use a global variable to store the log-level threshold. The application can then adjust this threshold dynamically, such as through a configuration file or runtime commands.

Header File (logger.h):

#ifndef LOGGER_H
#define LOGGER_H

typedef enum { DEBUG, INFO, WARNING, ERROR } LogLevel;

void set_log_level(LogLevel level);
void log_message(LogLevel level, const char* module, const char* text);

#endif // LOGGER_H
Enter fullscreen mode Exit fullscreen mode

Implementation File (logger.c):

#include "logger.h"
#include <stdio.h>
#include <time.h>
#include <string.h>

static LogLevel current_log_level = INFO;

void set_log_level(LogLevel level) {
    current_log_level = level;
}

void log_message(LogLevel level, const char* module, const char* text) {
    if (level < current_log_level) {
        return;
    }
    const char* level_strings[] = { "DEBUG", "INFO", "WARNING", "ERROR" };
    time_t now = time(NULL);
    printf("[%s][%s][%s] %s\n", ctime(&now), level_strings[level], module, text);
}
Enter fullscreen mode Exit fullscreen mode

This implementation makes it easy to control logging verbosity. For example, you could set the log level to DEBUG during a troubleshooting session and revert it to WARNING in production.

Managing Resources Properly

Proper resource management is crucial, especially when dealing with file operations or multiple logging destinations. Failing to close files or free allocated memory can lead to resource leaks, degrading system performance over time.

Ensure that any files opened for logging are properly closed when they are no longer needed. This can be achieved by implementing functions to initialize and shut down the logging system.

Implementation (logger.c):

#include "logger.h"
#include <stdio.h>
#include <stdlib.h>

static FILE* log_file = NULL;

void init_logging(const char* filename) {
    if (filename) {
        log_file = fopen(filename, "a");
        if (!log_file) {
            fprintf(stderr, "Failed to open log file: %s\n", filename);
            exit(EXIT_FAILURE);
        }
    } else {
        log_file = stdout; // Default to standard output
    }
}

void close_logging() {
    if (log_file && log_file != stdout) {
        fclose(log_file);
        log_file = NULL;
    }
}

void log_message(const char* text) {
    if (!log_file) {
        fprintf(stderr, "Logging not initialized.\n");
        return;
    }
    time_t now = time(NULL);
    fprintf(log_file, "[%s] %s\n", ctime(&now), text);
    fflush(log_file); // Ensure the message is written immediately
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    init_logging("application.log");

    log_message("Application started");
    log_message("Performing operation...");
    log_message("Operation completed.");

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

gcc -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

This will write the log messages to application.log. By providing init_logging and close_logging functions, you give the application control over the lifecycle of logging resources, preventing leaks and access issues.

Ensuring Thread Safety

In multithreaded applications, logging functions must be thread-safe to prevent race conditions and ensure log messages are not interleaved or corrupted.

One way to achieve thread safety is by using mutexes or other synchronization mechanisms.

Implementation (logger.c):

#include "logger.h"
#include <pthread.h>

static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;

void log_message(const char* text) {
    pthread_mutex_lock(&log_mutex);
    // Existing logging code
    if (!log_file) {
        fprintf(stderr, "Logging not initialized.\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }
    time_t now = time(NULL);
    fprintf(log_file, "[%s] %s\n", ctime(&now), text);
    fflush(log_file);
    pthread_mutex_unlock(&log_mutex);
}
Enter fullscreen mode Exit fullscreen mode

Usage in a Multithreaded Environment (main.c):

#include "logger.h"
#include <pthread.h>

void* thread_function(void* arg) {
    char* thread_name = (char*)arg;
    for (int i = 0; i < 5; i++) {
        char message[50];
        sprintf(message, "%s: Operation %d", thread_name, i + 1);
        log_message(message);
    }
    return NULL;
}

int main() {
    init_logging("application.log");

    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, thread_function, "Thread1");
    pthread_create(&thread2, NULL, thread_function, "Thread2");

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

gcc -pthread -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

This ensures that logs from different threads do not interfere with each other, maintaining the integrity of log messages.

External and Dynamic Configuration

Allowing logging configurations to be set externally enhances flexibility. Configurations like log levels, enabled modules, and destinations can be loaded from configuration files or set via command-line arguments.

Configuration File (config.cfg):

log_level=DEBUG
log_file=application.log
Enter fullscreen mode Exit fullscreen mode

Implementation (logger.c):

#include "logger.h"
#include <stdio.h>
#include <string.h>

void load_config(const char* config_file) {
    FILE* file = fopen(config_file, "r");
    if (!file) {
        fprintf(stderr, "Failed to open config file: %s\n", config_file);
        return;
    }

    char line[128];
    while (fgets(line, sizeof(line), file)) {
        if (strncmp(line, "log_level=", 10) == 0) {
            char* level = line + 10;
            level[strcspn(level, "\n")] = '\0'; // Remove newline
            if (strcmp(level, "DEBUG") == 0) set_log_level(DEBUG);
            else if (strcmp(level, "INFO") == 0) set_log_level(INFO);
            else if (strcmp(level, "WARNING") == 0) set_log_level(WARNING);
            else if (strcmp(level, "ERROR") == 0) set_log_level(ERROR);
        } else if (strncmp(line, "log_file=", 9) == 0) {
            char* filename = line + 9;
            filename[strcspn(filename, "\n")] = '\0'; // Remove newline
            init_logging(filename);
        }
    }
    fclose(file);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    load_config("config.cfg");

    log_message(INFO, "MAIN", "Application started");
    log_message(DEBUG, "MAIN", "This is a debug message");
    log_message(ERROR, "MAIN", "An error occurred");

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

gcc -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

By implementing dynamic configuration, you can adjust logging behavior without recompiling the application, which is particularly useful in production environments.

Custom Log Formatting

Customizing the format of log messages can make them more informative and easier to parse, especially when integrating with log analysis tools.

Implementation (logger.c):

void log_message(LogLevel level, const char* module, const char* text) {
    pthread_mutex_lock(&log_mutex);
    if (!log_file) {
        fprintf(stderr, "Logging not initialized.\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }

    const char* level_strings[] = { "DEBUG", "INFO", "WARNING", "ERROR" };
    time_t now = time(NULL);
    struct tm* local_time = localtime(&now);

    fprintf(log_file, "%04d-%02d-%02d %02d:%02d:%02d [%s][%s] %s\n",
            local_time->tm_year + 1900, local_time->tm_mon + 1,
            local_time->tm_mday, local_time->tm_hour, local_time->tm_min,
            local_time->tm_sec, level_strings[level], module, text);
    fflush(log_file);
    pthread_mutex_unlock(&log_mutex);
}
Enter fullscreen mode Exit fullscreen mode

Sample Output:

2023-10-05 14:00:00 [INFO][MAIN] Application started
Enter fullscreen mode Exit fullscreen mode

For structured logging, consider outputting logs in JSON format:

void log_message_json(LogLevel level, const char* module, const char* text) {
    pthread_mutex_lock(&log_mutex);
    if (!log_file) {
        fprintf(stderr, "Logging not initialized.\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }

    const char* level_strings[] = { "DEBUG", "INFO", "WARNING", "ERROR" };
    time_t now = time(NULL);

    fprintf(log_file, "{ \"timestamp\": %ld, \"level\": \"%s\", \"module\": \"%s\", \"message\": \"%s\" }\n",
            now, level_strings[level], module, text);
    fflush(log_file);
    pthread_mutex_unlock(&log_mutex);
}
Enter fullscreen mode Exit fullscreen mode

This format is suitable for parsing by log management tools.

Internal Error Handling

The logging system itself may encounter errors, such as failing to open a file or issues with resource allocation. It's important to handle these errors gracefully and provide feedback to the developer.

Implementation (logger.c):

void log_message(const char* text) {
    pthread_mutex_lock(&log_mutex);
    if (!text) {
        fprintf(stderr, "Invalid log message: NULL pointer\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }
    if (!log_file) {
        fprintf(stderr, "Logging not initialized.\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }
    // Existing logging code
    pthread_mutex_unlock(&log_mutex);
}
Enter fullscreen mode Exit fullscreen mode

By checking the state of resources before use and providing meaningful error messages, you can prevent crashes and aid in troubleshooting issues with the logging system itself.

Performance and Efficiency

Logging can impact application performance, especially if logging is extensive or performed synchronously. To mitigate this, consider techniques like buffering logs or performing logging operations asynchronously.

Asynchronous Logging Implementation (logger.c):

#include <pthread.h>
#include <stdlib.h>
#include <string.h>

typedef struct LogEntry {
    char* message;
    struct LogEntry* next;
} LogEntry;

static LogEntry* log_queue_head = NULL;
static LogEntry* log_queue_tail = NULL;
static pthread_mutex_t queue_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t queue_cond = PTHREAD_COND_INITIALIZER;
static pthread_t log_thread;
static int logging_active = 1;

void* log_worker(void* arg) {
    while (1) {
        pthread_mutex_lock(&queue_mutex);
        while (!log_queue_head && logging_active) {
            pthread_cond_wait(&queue_cond, &queue_mutex);
        }
        if (!logging_active && !log_queue_head) {
            pthread_mutex_unlock(&queue_mutex);
            break;
        }
        LogEntry* entry = log_queue_head;
        log_queue_head = entry->next;
        pthread_mutex_unlock(&queue_mutex);

        // Write the log message
        pthread_mutex_lock(&log_mutex);
        if (log_file) {
            fprintf(log_file, "%s\n", entry->message);
            fflush(log_file);
        }
        pthread_mutex_unlock(&log_mutex);

        free(entry->message);
        free(entry);
    }
    return NULL;
}

void init_logging(const char* filename) {
    // Existing initialization code
    init_logging(filename);

    // Start the logging thread
    pthread_create(&log_thread, NULL, log_worker, NULL);
}

void close_logging() {
    // Signal the logging thread to exit
    pthread_mutex_lock(&queue_mutex);
    logging_active = 0;
    pthread_cond_signal(&queue_cond);
    pthread_mutex_unlock(&queue_mutex);

    // Wait for the logging thread to finish
    pthread_join(log_thread, NULL);

    // Existing cleanup code
    close_logging();
}

void log_message_async(const char* text) {
    LogEntry* entry = malloc(sizeof(LogEntry));
    entry->message = strdup(text);
    entry->next = NULL;

    pthread_mutex_lock(&queue_mutex);
    if (log_queue_tail) {
        log_queue_tail->next = entry;
    } else {
        log_queue_head = entry;
    }
    log_queue_tail = entry;
    pthread_cond_signal(&queue_cond);
    pthread_mutex_unlock(&queue_mutex);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    init_logging("application.log");

    log_message_async("Application started");
    log_message_async("Performing operation...");
    log_message_async("Operation completed.");

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Using asynchronous logging reduces the time the main application threads spend on logging, improving overall performance.

Security Best Practices

Logs can inadvertently expose sensitive information, such as passwords or personal data. It's crucial to avoid logging such information and to protect log files from unauthorized access.

Implementation (logger.c):

void log_message(const char* text) {
    // Sanitize input to prevent logging sensitive data
    if (strstr(text, "password") || strstr(text, "secret")) {
        fprintf(stderr, "Attempt to log sensitive information blocked.\n");
        return;
    }
    // Existing logging code
}
Enter fullscreen mode Exit fullscreen mode

Setting File Permissions:

void init_logging(const char* filename) {
    // Existing initialization code
    if (filename && log_file) {
        chmod(filename, S_IRUSR | S_IWUSR); // Owner can read and write
    }
}
Enter fullscreen mode Exit fullscreen mode

Recommendations:

  • Sanitize Inputs: Ensure that sensitive data is not included in log messages.
  • Access Control: Set appropriate permissions on log files to restrict access.
  • Encryption: Consider encrypting log files if they contain sensitive information.
  • Log Rotation: Implement log rotation to prevent logs from growing indefinitely and to manage exposure.

By following these practices, you enhance the security of your application and comply with data protection regulations.

Integrating with Logging Tools

Modern applications often integrate with external logging tools and services for better log management and analysis.

Syslog Integration (logger.c):

#include <syslog.h>

void init_logging_syslog() {
    openlog("MyApp", LOG_PID | LOG_CONS, LOG_USER);
}

void close_logging_syslog() {
    closelog();
}

void log_message_syslog(LogLevel level, const char* module, const char* text) {
    int syslog_levels[] = { LOG_DEBUG, LOG_INFO, LOG_WARNING, LOG_ERR };
    syslog(syslog_levels[level], "[%s] %s", module, text);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    init_logging_syslog();

    log_message_syslog(INFO, "MAIN", "Application started");
    log_message_syslog(ERROR, "MAIN", "An error occurred");

    close_logging_syslog();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Remote Logging Services:

To send logs to remote services like Graylog or Elasticsearch, you can use network sockets or specialized libraries.

Example using sockets (logger.c):

#include <sys/socket.h>
#include <arpa/inet.h>

static int sockfd;
static struct sockaddr_in server_addr;

void init_logging_remote(const char* server_ip, int port) {
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        fprintf(stderr, "Failed to create socket\n");
        exit(EXIT_FAILURE);
    }
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);
    inet_pton(AF_INET, server_ip, &server_addr.sin_addr);
}

void log_message_remote(const char* text) {
    sendto(sockfd, text, strlen(text), 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
}

void close_logging_remote() {
    close(sockfd);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    init_logging_remote("192.168.1.100", 514);

    log_message_remote("Application started");
    log_message_remote("An error occurred");

    close_logging_remote();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Integration with external tools can provide advanced features like centralized log management, real-time monitoring, and alerting.

Testing and Validation

Thorough testing ensures that the logging system functions correctly under various conditions.

Unit Test Example (test_logger.c):

#include "logger.h"
#include <assert.h>

void test_log_message() {
    init_logging(NULL);
    log_message("Test message");
    close_logging();
    // Manually verify that the message was printed to stdout
}

void test_log_file() {
    init_logging("test.log");
    log_message("Test message to file");
    close_logging();
    // Check that "test.log" contains the message
}

int main() {
    test_log_message();
    test_log_file();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running Tests:

gcc -o test_logger test_logger.c logger.c
./test_logger
Enter fullscreen mode Exit fullscreen mode

Testing Strategies:

  • Unit Tests: Validate individual functions.
  • Stress Tests: Simulate high-frequency logging.
  • Multithreaded Tests: Log from multiple threads concurrently.
  • Failure Injection: Simulate errors like disk full or network failure.

By rigorously testing the logging system, you can identify and fix issues before they affect the production environment.

Cross-Platform File Logging

Cross-platform compatibility is a necessity for modern software. While the previous examples work well on Unix-based systems, they may not function on Windows due to differences in file handling APIs. To address this, you need a cross-platform logging mechanism.

Implementation (logger.c):

#include "logger.h"

#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#include <fcntl.h>
#endif

void* open_log_file(const char* filename) {
#ifdef _WIN32
    HANDLE file = CreateFileA(
        filename, GENERIC_WRITE, FILE_SHARE_READ, NULL,
        OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (file == INVALID_HANDLE_VALUE) {
        fprintf(stderr, "Failed to open log file on Windows\n");
        return NULL;
    }
    SetFilePointer(file, 0, NULL, FILE_END); // Append mode
    return file;
#else
    int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd < 0) {
        fprintf(stderr, "Failed to open log file on Unix\n");
        return NULL;
    }
    return (void*)(intptr_t)fd;
#endif
}

void write_to_log_file(void* file, const char* message) {
#ifdef _WIN32
    DWORD bytes_written;
    WriteFile(file, message, strlen(message), &bytes_written, NULL);
    WriteFile(file, "\n", 1, &bytes_written, NULL);
#else
    int fd = (int)(intptr_t)file;
    write(fd, message, strlen(message));
    write(fd, "\n", 1);
#endif
}

void close_log_file(void* file) {
#ifdef _WIN32
    CloseHandle(file);
#else
    int fd = (int)(intptr_t)file;
    close(fd);
#endif
}
Enter fullscreen mode Exit fullscreen mode

Usage (logger.c):

static void* log_file_handle = NULL;

void init_logging(const char* filename) {
    log_file_handle = open_log_file(filename);
    if (!log_file_handle) {
        fprintf(stderr, "Failed to initialize logging\n");
        exit(EXIT_FAILURE);
    }
}

void log_message(const char* text) {
    // Existing code...
    write_to_log_file(log_file_handle, text);
}

void close_logging() {
    if (log_file_handle) {
        close_log_file(log_file_handle);
        log_file_handle = NULL;
    }
}
Enter fullscreen mode Exit fullscreen mode

By isolating platform-specific details, you ensure that the main logging logic remains clean and consistent.

Wrapping It All Up

Designing a logging system might seem like a straightforward task at first glance, but as we've seen, it involves numerous decisions that impact functionality, performance, and maintainability. By using design patterns and structured approaches, you can create a logging system that is robust, extensible, and easy to integrate.

From organizing files to implementing cross-platform compatibility, each step builds upon the previous one to form a cohesive whole. The system can filter logs by module, adjust verbosity through log levels, support multiple destinations, and handle resources properly. It ensures thread safety, allows for external configuration, supports custom formatting, and adheres to security best practices.

By embracing patterns like Stateless Design, Dynamic Interfaces, and Abstraction Layers, you avoid common pitfalls and make your codebase future-proof. Whether you're working on a small utility or a large-scale application, these principles are invaluable.

The effort you invest in building a well-designed logging system pays off in reduced debugging time, better insights into application behavior, and happier stakeholders. With this foundation, you're now equipped to handle the logging needs of even the most complex projects.

Extra: Enhancing the Logging System

In this extra section, we'll address some areas for improvement identified earlier to enhance the logging system we've built. We'll focus on refining code consistency, improving error handling, clarifying complex concepts, and expanding on testing and validation. Each topic includes introductory text, practical examples that can be compiled, and external references for further learning.

1. Code Consistency and Formatting

Consistent code formatting and naming conventions improve readability and maintainability. We'll standardize variable and function names using snake_case, which is common in C programming.

Updated Implementation (logger.h):

#ifndef LOGGER_H
#define LOGGER_H

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

typedef enum { DEBUG, INFO, WARNING, ERROR } log_level_t;

void init_logging(const char* filename);
void close_logging();
void set_log_level(log_level_t level);
void log_message(log_level_t level, const char* module, const char* message);

#endif // LOGGER_H
Enter fullscreen mode Exit fullscreen mode

Updated Implementation (logger.c):

#include "logger.h"
#include <stdlib.h>
#include <string.h>
#include <assert.h>

static FILE* log_file = NULL;
static log_level_t current_log_level = INFO;
static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER;

void init_logging(const char* filename) {
    if (filename) {
        log_file = fopen(filename, "a");
        if (!log_file) {
            fprintf(stderr, "Error: Failed to open log file '%s'\n", filename);
            exit(EXIT_FAILURE);
        }
    } else {
        log_file = stdout;
    }
}

void close_logging() {
    if (log_file && log_file != stdout) {
        fclose(log_file);
        log_file = NULL;
    }
}

void set_log_level(log_level_t level) {
    current_log_level = level;
}

void log_message(log_level_t level, const char* module, const char* message) {
    assert(module != NULL);
    assert(message != NULL);

    if (level < current_log_level) {
        return;
    }

    pthread_mutex_lock(&log_mutex);

    if (!log_file) {
        fprintf(stderr, "Error: Logging not initialized\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }

    const char* level_strings[] = { "DEBUG", "INFO", "WARNING", "ERROR" };
    time_t now = time(NULL);
    struct tm* local_time = localtime(&now);

    fprintf(log_file, "%04d-%02d-%02d %02d:%02d:%02d [%s][%s] %s\n",
            local_time->tm_year + 1900, local_time->tm_mon + 1,
            local_time->tm_mday, local_time->tm_hour, local_time->tm_min,
            local_time->tm_sec, level_strings[level], module, message);

    fflush(log_file);
    pthread_mutex_unlock(&log_mutex);
}
Enter fullscreen mode Exit fullscreen mode

Updated Usage (main.c):

#include "logger.h"

int main() {
    init_logging("app.log");
    set_log_level(DEBUG);

    log_message(INFO, "MAIN", "Application started");
    log_message(DEBUG, "MAIN", "Debugging application flow");
    log_message(WARNING, "NETWORK", "Network latency detected");
    log_message(ERROR, "DATABASE", "Database connection failed");

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

gcc -pthread -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

External References:

2. Improved Error Handling

Robust error handling ensures the application can gracefully handle unexpected situations.

Enhanced Error Checking (logger.c):

void init_logging(const char* filename) {
    pthread_mutex_lock(&log_mutex);
    if (filename) {
        log_file = fopen(filename, "a");
        if (!log_file) {
            fprintf(stderr, "Error: Failed to open log file '%s'\n", filename);
            pthread_mutex_unlock(&log_mutex);
            exit(EXIT_FAILURE);
        }
    } else {
        log_file = stdout;
    }
    pthread_mutex_unlock(&log_mutex);
}

void log_message(log_level_t level, const char* module, const char* message) {
    if (!module || !message) {
        fprintf(stderr, "Error: Null parameter passed to log_message\n");
        return;
    }

    if (pthread_mutex_lock(&log_mutex) != 0) {
        fprintf(stderr, "Error: Failed to acquire log mutex\n");
        return;
    }

    if (!log_file) {
        fprintf(stderr, "Error: Logging not initialized\n");
        pthread_mutex_unlock(&log_mutex);
        return;
    }

    // Existing logging code...

    if (pthread_mutex_unlock(&log_mutex) != 0) {
        fprintf(stderr, "Error: Failed to release log mutex\n");
    }
}
Enter fullscreen mode Exit fullscreen mode

External References:

3. Clarifying Asynchronous Logging

Asynchronous logging improves performance by decoupling the logging process from the main application flow. Here's a detailed explanation with a practical example.

Implementation (logger.c):

#include <pthread.h>
#include <stdlib.h>
#include <string.h>

typedef struct log_entry_t {
    char* message;
    struct log_entry_t* next;
} log_entry_t;

static log_entry_t* log_queue_head = NULL;
static log_entry_t* log_queue_tail = NULL;
static pthread_mutex_t queue_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t queue_cond = PTHREAD_COND_INITIALIZER;
static pthread_t log_thread;
static int logging_active = 1;

void* log_worker(void* arg) {
    while (1) {
        pthread_mutex_lock(&queue_mutex);
        while (!log_queue_head && logging_active) {
            pthread_cond_wait(&queue_cond, &queue_mutex);
        }
        if (!logging_active && !log_queue_head) {
            pthread_mutex_unlock(&queue_mutex);
            break;
        }
        log_entry_t* entry = log_queue_head;
        log_queue_head = entry->next;
        if (!log_queue_head) {
            log_queue_tail = NULL;
        }
        pthread_mutex_unlock(&queue_mutex);

        // Log the message
        pthread_mutex_lock(&log_mutex);
        if (log_file) {
            fprintf(log_file, "%s\n", entry->message);
            fflush(log_file);
        }
        pthread_mutex_unlock(&log_mutex);

        free(entry->message);
        free(entry);
    }
    return NULL;
}

void init_logging(const char* filename) {
    // Existing initialization code
    init_logging(filename);

    // Start the logging thread
    if (pthread_create(&log_thread, NULL, log_worker, NULL) != 0) {
        fprintf(stderr, "Error: Failed to create log worker thread\n");
        exit(EXIT_FAILURE);
    }
}

void close_logging() {
    // Signal the logging thread to exit
    pthread_mutex_lock(&queue_mutex);
    logging_active = 0;
    pthread_cond_signal(&queue_cond);
    pthread_mutex_unlock(&queue_mutex);

    // Wait for the logging thread to finish
    if (pthread_join(log_thread, NULL) != 0) {
        fprintf(stderr, "Error: Failed to join log worker thread\n");
    }

    // Existing cleanup code
    close_logging();
}

void log_message_async(const char* message) {
    if (!message) {
        fprintf(stderr, "Error: Null message passed to log_message_async\n");
        return;
    }

    log_entry_t* entry = malloc(sizeof(log_entry_t));
    if (!entry) {
        fprintf(stderr, "Error: Failed to allocate memory for log entry\n");
        return;
    }
    entry->message = strdup(message);
    entry->next = NULL;

    pthread_mutex_lock(&queue_mutex);
    if (log_queue_tail) {
        log_queue_tail->next = entry;
    } else {
        log_queue_head = entry;
    }
    log_queue_tail = entry;
    pthread_cond_signal(&queue_cond);
    pthread_mutex_unlock(&queue_mutex);
}
Enter fullscreen mode Exit fullscreen mode

Usage (main.c):

#include "logger.h"

int main() {
    init_logging("app.log");

    for (int i = 0; i < 1000; i++) {
        char message[100];
        snprintf(message, sizeof(message), "Log message %d", i);
        log_message_async(message);
    }

    close_logging();
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running:

gcc -pthread -o app main.c logger.c
./app
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Producer-Consumer Model: The main thread produces log messages and adds them to a queue. The log worker thread consumes messages from the queue and writes them to the log file.
  • Thread Synchronization: Mutexes and condition variables ensure thread-safe access to shared resources.
  • Graceful Shutdown: The logging_active flag and condition variable signal the worker thread to exit when logging is closed.

External References:

4. Expanding Testing and Validation

Testing is crucial to ensure the logging system functions correctly under various conditions.

Using Unity Test Framework:

Unity is a lightweight testing framework for C.

Setup:

  1. Download Unity from the official repository: Unity on GitHub
  2. Include unity.h in your test files.

Test File (test_logger.c):

#include "unity.h"
#include "logger.h"
#include <stdio.h>
#include <stdlib.h>

void setUp(void) {
    // This function is run before each test
    init_logging(NULL);
}

void tearDown(void) {
    // This function is run after each test
    close_logging();
}

void test_log_message_stdout(void) {
    // Redirect stdout to a file
    FILE* temp_stdout = freopen("stdout.log", "w", stdout);
    log_message(INFO, "TEST", "Test message to stdout");
    fclose(temp_stdout);

    // Read the content of stdout.log
    FILE* file = fopen("stdout.log", "r");
    char buffer[256];
    fgets(buffer, sizeof(buffer), file);
    fclose(file);

    TEST_ASSERT_TRUE_MESSAGE(strstr(buffer, "Test message to stdout") != NULL,
                             "Log message not found in stdout.log");
}

void test_log_message_file(void) {
    init_logging("test.log");
    log_message(INFO, "TEST", "Test message to file");
    close_logging();

    FILE* file = fopen("test.log", "r");
    TEST_ASSERT_NOT_NULL_MESSAGE(file, "Failed to open test.log");

    char buffer[256];
    fgets(buffer, sizeof(buffer), file);
    fclose(file);

    TEST_ASSERT_TRUE_MESSAGE(strstr(buffer, "Test message to file") != NULL,
                             "Log message not found in test.log");
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_log_message_stdout);
    RUN_TEST(test_log_message_file);
    return UNITY_END();
}
Enter fullscreen mode Exit fullscreen mode

Compiling and Running Tests:

gcc -o test_logger test_logger.c logger.c unity.c -I unity
./test_logger
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • setUp and tearDown: Functions run before and after each test for setup and cleanup.
  • Assertions: Use TEST_ASSERT_* macros to validate conditions.
  • Test Cases: Tests cover logging to stdout and to a file.

External References:

5. Security Enhancements

Ensuring the logging system is secure is essential, especially when dealing with sensitive data.

Secure Transmission with TLS:

For sending logs over the network securely, use TLS encryption.

Implementation Using OpenSSL (logger.c):

#include <openssl/ssl.h>
#include <openssl/err.h>

static SSL_CTX* ssl_ctx;
static SSL* ssl;

void init_logging_remote_secure(const char* server_ip, int port) {
    SSL_library_init();
    SSL_load_error_strings();
    ssl_ctx = SSL_CTX_new(TLS_client_method());
    if (!ssl_ctx) {
        fprintf(stderr, "Error: Failed to create SSL context\n");
        exit(EXIT_FAILURE);
    }

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // Set up server address and connect...

    ssl = SSL_new(ssl_ctx);
    SSL_set_fd(ssl, sockfd);
    if (SSL_connect(ssl) <= 0) {
        fprintf(stderr, "Error: Failed to establish SSL connection\n");
        exit(EXIT_FAILURE);
    }
}

void log_message_remote_secure(const char* message) {
    if (SSL_write(ssl, message, strlen(message)) <= 0) {
        fprintf(stderr, "Error: Failed to send log message over SSL\n");
    }
}

void close_logging_remote_secure() {
    SSL_shutdown(ssl);
    SSL_free(ssl);
    SSL_CTX_free(ssl_ctx);
}
Enter fullscreen mode Exit fullscreen mode

External References:

Compliance with Data Protection Regulations:

When logging personal data, ensure compliance with regulations like GDPR.

Recommendations:

  • Anonymization: Remove or mask personal identifiers in logs.
  • Access Control: Restrict access to log files.
  • Data Retention Policies: Define how long logs are stored.

External References:

6. Utilizing Existing Logging Libraries

Sometimes, using a well-established logging library can save time and provide additional features.

Introduction to zlog:

zlog is a reliable, thread-safe, and highly configurable logging library for C.

Features:

  • Configuration via files.
  • Support for multiple log categories and levels.
  • Asynchronous logging capabilities.

Usage Example:

  1. Installation:
   sudo apt-get install libzlog-dev
Enter fullscreen mode Exit fullscreen mode
  1. Configuration File (zlog.conf):
   [formats]
   simple = "%d %V [%p] %m%n"

   [rules]
   my_cat.DEBUG > my_log; simple
Enter fullscreen mode Exit fullscreen mode
  1. Implementation (main.c):
   #include <zlog.h>

   int main() {
       if (zlog_init("zlog.conf")) {
           printf("Error: zlog initialization failed\n");
           return -1;
       }
       zlog_category_t *c = zlog_get_category("my_cat");
       if (!c) {
           printf("Error: zlog get category failed\n");
           zlog_fini();
           return -2;
       }
       zlog_info(c, "Hello, zlog!");
       zlog_fini();
       return 0;
   }
Enter fullscreen mode Exit fullscreen mode
  1. Compiling and Running:
   gcc -o app main.c -lzlog
   ./app
Enter fullscreen mode Exit fullscreen mode

External References:

Comparison with Custom Implementation:

  • Advantages of Using Libraries:

    • Saves development time.
    • Offers advanced features.
    • Well-tested and maintained.
  • Disadvantages:

    • May include unnecessary features.
    • Adds external dependencies.
    • Less control over internal workings.

7. Enhancing the Conclusion

To wrap up, let's reinforce the key takeaways and encourage further exploration.

Final Thoughts:

Building a robust logging system is a critical aspect of software development. By focusing on code consistency, error handling, clarity, testing, security, and leveraging existing tools when appropriate, you create a foundation that enhances the maintainability and reliability of your applications.

Call to Action:

  • Apply the Concepts: Integrate these enhancements into your projects.
  • Explore Further: Investigate more advanced logging features like log rotation, filtering, and analysis tools.
  • Stay Updated: Keep abreast of best practices and emerging technologies in logging and software development.

Additional Resources:

Top comments (0)