When I started researching logging failures for this post, I expected to find dramatic exploit chains. Instead, what I found was something more unsettling, the absence of evidence. The most frustrating thing about incident response isn’t finding a sophisticated exploit; it’s opening the log aggregator and finding nothing. No entries, no breadcrumbs, no evidence that anything happened at all. That’s CWE-778 (Insufficient Logging), and it’s the backbone of OWASP A09: Security Logging and Monitoring Failures. This isn’t a crash or a data leak in the traditional sense; it’s the absence of evidence. When your incident response team can’t investigate what was never recorded, the attacker wins by default. In this post, I’m going to walk through logging failures across Python, Java, and Go, from the obvious missing-log-statement to the subtle cases where logging exists but captures the wrong data, at the wrong level, or silently drops events under load.

Why Logging Failures Matter

Here’s what I find interesting about logging failures: they’re force multipliers for every other vulnerability class. An SQL injection that goes unlogged can be exploited for months before anyone notices. A brute-force attack against an authentication endpoint that produces no log entries will never trigger an alert. The 2021 OWASP Top 10 elevated logging and monitoring failures to A09 specifically because breach investigations kept showing the same thing, organizations had no logs, or useless logs, covering the attack window.

The more I dug into real-world breach reports, the more the core patterns became clear:

  • Missing logs entirely: Security-critical operations (login, access control decisions, input validation failures) produce no log output.
  • Logging at the wrong level: Security events logged at DEBUG or TRACE, which are disabled in production.
  • Swallowed exceptions: Catch blocks that silently discard errors, destroying evidence of exploitation attempts.
  • Missing context: Logs that record “request failed” without the who, what, when, or where needed for investigation.
  • Log injection: Untrusted input written directly into log messages, allowing attackers to forge log entries or inject control characters.

The Easy-to-Spot Version

Python: Silent Exception Swallowing

from flask import Flask, request, jsonify
import sqlite3

app = Flask(__name__)

@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
    api_key = request.headers.get("X-API-Key", "")
    try:
        conn = sqlite3.connect("app.db")
        cursor = conn.cursor()
        cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
        conn.commit()
        conn.close()
        return jsonify({"message": "User deleted"})
    except Exception:
        return jsonify({"error": "Operation failed"}), 500

One thing that jumped out at me when I first looked at code like this: it’s an admin endpoint that deletes users, and it produces zero log output. There’s no record of who called it, which user was deleted, whether the API key was valid, or whether the operation succeeded. That bare except Exception swallows every error, database corruption, permission failures, constraint violations, and returns a generic error with no trace in any log file.

This pattern shows up in production code more often than you’d think. An attacker who compromises an API key can delete every user in the database, and the only evidence will be the missing rows.

Java: Security Events at DEBUG Level

import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RestController
public class AuthController {
    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);

    @PostMapping("/api/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest req) {
        User user = userService.findByUsername(req.getUsername());
        if (user == null || !passwordEncoder.matches(req.getPassword(), user.getHash())) {
            logger.debug("Login failed for user: {}", req.getUsername());
            return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
        }
        logger.debug("Login succeeded for user: {}", req.getUsername());
        String token = jwtService.generateToken(user);
        return ResponseEntity.ok(Map.of("token", token));
    }
}

This one is a great example of logging that looks like it’s there but isn’t really. Someone clearly thought about it, the log statements exist, but they’re at DEBUG level. Production Spring Boot applications typically run at INFO or WARN. So every login attempt, successful or failed, is invisible in production logs. A brute-force attack generating thousands of failed logins per minute will produce no alerts because the events are never written. What clicked for me here is that the log level choice effectively makes the logging decorative, it exists in the source code but not in the running system.

Go: Discarded Errors

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
)

func transferFunds(w http.ResponseWriter, r *http.Request) {
    var req TransferRequest
    json.NewDecoder(r.Body).Decode(&req)

    tx, _ := db.Begin()
    _, _ = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", req.Amount, req.FromID)
    _, _ = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", req.Amount, req.ToID)
    tx.Commit()

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

Go’s explicit error returns are one of the things I appreciate most about the language, but they also make it incredibly easy to discard errors by assigning them to _. This fund transfer endpoint ignores every error: the JSON decode, the transaction begin, both SQL executions, and the commit. If the transaction partially fails, debiting one account but failing to credit the other, there’s no log, no error response, and no way to detect the inconsistency. The HTTP response always says “ok.” Reading through post-mortems of financial bugs, this kind of silent failure is a recurring theme, and it’s easy to see why once you look at the code.

The Hard-to-Spot Version

Python: Logging That Looks Complete but Misses the Critical Path

import logging
from flask import Flask, request, jsonify

app = Flask(__name__)
logger = logging.getLogger("audit")

@app.route("/api/documents/<doc_id>/share", methods=["POST"])
def share_document(doc_id):
    logger.info("Share request received for document %s", doc_id)
    data = request.get_json()
    target_email = data.get("email", "")
    permission = data.get("permission", "read")

    if not is_valid_email(target_email):
        logger.warning("Invalid email format: %s", target_email)
        return jsonify({"error": "Invalid email"}), 400

    doc = get_document(doc_id)
    if doc is None:
        logger.warning("Document %s not found", doc_id)
        return jsonify({"error": "Not found"}), 404

    if permission not in ("read", "write", "admin"):
        logger.warning("Invalid permission level: %s", permission)
        return jsonify({"error": "Invalid permission"}), 400

    # The critical authorization check, but no logging on failure
    if not user_can_share(request.user, doc, permission):
        return jsonify({"error": "Forbidden"}), 403

    grant_access(target_email, doc, permission)
    logger.info("Document %s shared with %s at %s level", doc_id, target_email, permission)
    return jsonify({"message": "Shared"})

This is the kind of code that can fool you during review if you’re not careful. It looks well-logged, there are INFO messages for the request and the success, WARNING messages for validation failures. But look at the authorization failure on the user_can_share check. That’s the most security-critical branch in the whole function, and it produces no log entry. An attacker probing for documents they can escalate permissions on will generate 403 responses that leave no trace. What I found interesting researching this pattern is how often the logging creates a false sense of coverage, the presence of some log statements makes reviewers assume all paths are covered.

Java: Logger Configuration That Silently Drops Events

import java.util.logging.*;

public class AuditService {
    private static final Logger auditLogger = Logger.getLogger("security.audit");

    static {
        auditLogger.setLevel(Level.ALL);
        try {
            FileHandler fh = new FileHandler("/var/log/app/audit.log", 5_000_000, 3, true);
            fh.setFormatter(new SimpleFormatter());
            auditLogger.addHandler(fh);
        } catch (Exception e) {
            // If log file setup fails, continue without file logging
        }
    }

    public void logAuthEvent(String username, String action, boolean success) {
        auditLogger.info(String.format("AUTH event=%s user=%s success=%b", action, username, success));
    }

    public void logAccessDenied(String username, String resource) {
        auditLogger.warning(String.format("ACCESS_DENIED user=%s resource=%s", username, resource));
    }
}

What I find sneaky about this one is that it looks like someone put real thought into the audit logging. But there are two problems worth flagging. First, the logger is configured to write to a file with a 5 MB rotation and only 3 backup files. Under sustained attack, a brute-force attempt generating thousands of log entries per second will rotate through all backup files in minutes, overwriting the evidence of the attack’s beginning. Second, and this is the part that really surprised me when I thought it through, if the FileHandler constructor throws (disk full, permission denied, path doesn’t exist), the catch block silently swallows the exception. The auditLogger still has the default console handler from the parent logger, but in containerized deployments where stdout is not captured, all audit events vanish into thin air.

Go: Structured Logging That Omits Request Identity

package main

import (
    "log/slog"
    "net/http"
)

func handleAdminAction(w http.ResponseWriter, r *http.Request) {
    action := r.URL.Query().Get("action")
    target := r.URL.Query().Get("target")

    slog.Info("admin action executed",
        "action", action,
        "target", target,
        "method", r.Method,
        "path", r.URL.Path,
    )

    executeAdminAction(action, target)
    w.WriteHeader(http.StatusOK)
}

This uses Go’s structured logging (slog) and honestly looks pretty thorough at first glance, it records the action, target, method, and path. But here’s what’s missing: the request identity. No source IP (r.RemoteAddr), no authenticated user, no session or token identifier. When you’re investigating who performed a destructive admin action, the log entry tells you what happened but not who did it. In multi-tenant systems behind a load balancer, this makes attribution completely impossible, something that only becomes obvious when you actually try to use the logs during an investigation.

Detection Strategies

Static Analysis

Tool Language What It Catches Limitations
Bandit Python Bare except clauses (B110), pass in except (B112) Cannot detect missing log statements in security-critical paths
SpotBugs Java Empty catch blocks Does not flag DEBUG-level logging of security events
gosec Go Unhandled errors (G104) Flags discarded errors but not missing log calls
Semgrep All Custom rules for empty catch blocks, missing logging patterns Requires project-specific rule authoring
SonarQube All Empty catch blocks, generic exception handling Limited detection of insufficient logging context

Manual Review Indicators

During code review, these are the patterns worth paying close attention to:

  1. Catch blocks with no logging: Any catch, except, or error-handling block that doesn’t call a logger.
  2. Security operations without audit trails: Authentication, authorization, data modification, and admin operations that produce no log output.
  3. DEBUG/TRACE level for security events: Login attempts, access denials, and privilege changes logged below INFO.
  4. Missing identity context: Log entries for security events that omit the user, IP address, or session identifier.
  5. Unbounded log rotation: File-based logging with small rotation limits and no external log aggregation.
  6. Error returns assigned to _ in Go: Any _ assignment on a function that returns an error, especially in transaction or I/O code.

Runtime Detection

  • Log volume monitoring: A sudden drop in log volume from a service may indicate that logging has failed silently. This is one of those detection techniques that sounds obvious but is surprisingly effective in practice.
  • Canary events: Periodically inject known events and verify they appear in the log aggregation system.
  • Coverage analysis: Compare the set of HTTP endpoints or RPC methods against the set of endpoints that produce log entries. Any gap is a logging failure.

Remediation

Python: Proper Exception Logging with Context

import logging
from flask import Flask, request, jsonify, g

app = Flask(__name__)
logger = logging.getLogger("security.audit")

@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
    api_key = request.headers.get("X-API-Key", "")
    caller = get_caller_identity(api_key)

    logger.info(
        "admin.delete_user requested",
        extra={
            "caller": caller,
            "target_user_id": user_id,
            "source_ip": request.remote_addr,
        },
    )

    try:
        conn = sqlite3.connect("app.db")
        cursor = conn.cursor()
        cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
        affected = cursor.rowcount
        conn.commit()
        conn.close()

        logger.info(
            "admin.delete_user completed",
            extra={
                "caller": caller,
                "target_user_id": user_id,
                "rows_affected": affected,
            },
        )
        return jsonify({"message": "User deleted"})

    except Exception:
        logger.exception(
            "admin.delete_user failed",
            extra={
                "caller": caller,
                "target_user_id": user_id,
            },
        )
        return jsonify({"error": "Operation failed"}), 500

Here’s what changed and why: every security-relevant operation now logs the caller identity, target, and source IP. The logger.exception() call in the except block captures the full stack trace. Structured extra fields enable log aggregation queries. This is the kind of logging that actually helps when things go sideways at 3 AM.

Java: Security Events at Appropriate Levels

import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RestController
public class AuthController {
    private static final Logger securityLogger = LoggerFactory.getLogger("security.auth");

    @PostMapping("/api/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest req, HttpServletRequest httpReq) {
        String sourceIp = httpReq.getRemoteAddr();
        User user = userService.findByUsername(req.getUsername());

        if (user == null || !passwordEncoder.matches(req.getPassword(), user.getHash())) {
            securityLogger.warn("LOGIN_FAILED user={} source_ip={}", req.getUsername(), sourceIp);
            return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
        }

        securityLogger.info("LOGIN_SUCCESS user={} source_ip={}", req.getUsername(), sourceIp);
        String token = jwtService.generateToken(user);
        return ResponseEntity.ok(Map.of("token", token));
    }
}

Failed logins are at WARN (always visible in production). Successful logins are at INFO. Both include the source IP for correlation. Using a dedicated security.auth logger like this lets you route security events to a separate log sink with longer retention, something that’s worth setting up from day one on any project.

Go: Handling Every Error with Context

package main

import (
    "encoding/json"
    "log/slog"
    "net/http"
)

func transferFunds(w http.ResponseWriter, r *http.Request) {
    var req TransferRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        slog.Error("transfer request decode failed",
            "error", err,
            "source_ip", r.RemoteAddr,
        )
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    tx, err := db.Begin()
    if err != nil {
        slog.Error("transfer transaction begin failed",
            "error", err,
            "from", req.FromID,
            "to", req.ToID,
        )
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", req.Amount, req.FromID)
    if err != nil {
        tx.Rollback()
        slog.Error("transfer debit failed",
            "error", err,
            "from", req.FromID,
            "amount", req.Amount,
        )
        http.Error(w, "transfer failed", http.StatusInternalServerError)
        return
    }

    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", req.Amount, req.ToID)
    if err != nil {
        tx.Rollback()
        slog.Error("transfer credit failed",
            "error", err,
            "to", req.ToID,
            "amount", req.Amount,
        )
        http.Error(w, "transfer failed", http.StatusInternalServerError)
        return
    }

    if err := tx.Commit(); err != nil {
        slog.Error("transfer commit failed",
            "error", err,
            "from", req.FromID,
            "to", req.ToID,
            "amount", req.Amount,
        )
        http.Error(w, "transfer failed", http.StatusInternalServerError)
        return
    }

    slog.Info("transfer completed",
        "from", req.FromID,
        "to", req.ToID,
        "amount", req.Amount,
        "source_ip", r.RemoteAddr,
    )
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

Every error is checked, logged with context, and results in an appropriate HTTP error response. The transaction is rolled back on partial failure. The success path logs the complete operation details including source IP. Yes, it’s more verbose than the original, but verbose and debuggable beats concise and silent every time.

Logging Best Practices Checklist

These are the practices that the research and real-world breach reports consistently point to as essential:

  1. Log all authentication events (login success, login failure, logout, token refresh) at INFO or WARN level.
  2. Log all authorization failures at WARN level with the user identity and requested resource.
  3. Log all data modification operations (create, update, delete) with the actor identity and affected records.
  4. Include identity context in every security log entry: user ID, source IP, session/request ID.
  5. Never log sensitive data: passwords, tokens, credit card numbers, or PII should be masked or omitted.
  6. Use structured logging (JSON, key-value pairs) to enable automated analysis and alerting.
  7. Send logs to an external aggregation system, local file rotation alone is insufficient for incident response.
  8. Monitor log pipeline health, alert on unexpected drops in log volume.
  9. Test logging in security-critical paths, include logging verification in integration tests.
  10. Never swallow exceptions silently, every catch/except/recover block should log or re-raise.