Log volume is a reliability and cost concern, but content-substring filtering is a dangerous fix. Dropping every message that contains /health, kube-probe, or ELB-HealthChecker can hide an exception whose text happens to include the same string. It also breaks when a proxy changes wording.

The safer boundary is logger identity: classify the request from structured HTTP state, send routine successful probes to a dedicated logger, keep business access events separate, and force every 5xx event onto an unsuppressed ERROR path.

I measured that design with two otherwise identical Spring Boot 4.1 containers. The baseline enabled health access logs; the filtered variant disabled only the dedicated health logger. Each received 2,000 health checks, 100 successful business requests, and 10 injected 5xx requests.

Decide which signal is redundant

Before suppressing a log, write down what would be lost and what equivalent evidence remains:

SignalKeep in logs?Replacement or reason
Successful health access eventUsually noCounter, status metric, and probe alert
Failed health requestYesIncident evidence; log at ERROR
Successful business access eventYes or sampledRequest audit and latency context
Business 5xxYesNever suppress through the health logger
Request body or credentialsNo by defaultHigh privacy and security risk

“The log platform is expensive” is not enough. Measure the source volume, prove another signal exists, and define how to restore samples during an incident.

Classify from the parsed request, not formatted text

The classifier uses an exact path comparison and gives errors priority:

final class AccessLogClassifier {
    static final String HEALTH_LOGGER = "lab.access.health";
    static final String APPLICATION_LOGGER = "lab.access.application";
    static final String ERROR_LOGGER = "lab.access.error";

    static String loggerFor(String path, int status) {
        if (status >= 500) return ERROR_LOGGER;
        return path.equals("/actuator/health")
                ? HEALTH_LOGGER
                : APPLICATION_LOGGER;
    }
}

/actuator/health is probe traffic. /api/errors/actuator/health is an application path and must not be suppressed. A 503 from /actuator/health goes to lab.access.error, not lab.access.health.

The filter emits structured fields after the response completes:

String path = request.getRequestURI();
if (path.equals("/actuator/health")) probeRequests.increment();

var logger = LoggerFactory.getLogger(
        AccessLogClassifier.loggerFor(path, response.getStatus()));

LoggingEventBuilder event = response.getStatus() >= 500
        ? logger.atError()
        : logger.atInfo();

event.addKeyValue("http.request.method", request.getMethod())
     .addKeyValue("url.path", path)
     .addKeyValue("http.response.status_code", response.getStatus())
     .addKeyValue("event.duration_ms", durationMs)
     .log("request completed");

The ERROR call is not cosmetic. During the experiment I found that routing a 5xx to lab.access.error while still calling atInfo() allowed an ERROR logger threshold to discard it. The corrected implementation selects both the error logger and the error level, and the measurement includes explicit 5xx requests so the regression cannot hide.

Disable one logger through configuration

Spring Boot structured logging produces machine-readable ECS JSON. The only difference between variants is the health logger level:

logging:
  structured:
    format:
      console: ecs
  level:
    lab.access.health: ${HEALTH_ACCESS_LOG_LEVEL:OFF}
    lab.access.application: INFO
    lab.access.error: ERROR

This is more stable than a Logback filter that inspects formattedMessage. The application has already decided what kind of event it is, and configuration decides whether that class is retained.

Structured fields also avoid fragile parsing. http.response.status_code=500 is safer to query than a regular expression over a custom sentence. Request IDs are validated and bounded before entering MDC; secrets and bodies are not logged.

Preserve the metric before removing the log

The filter increments a Micrometer counter independently of logging:

probeRequests = Counter.builder("http.probe.requests")
        .description("Health probe requests independent of access-log sampling")
        .register(registry);

Prometheus can alert on failed probes or unexpected probe volume without ingesting one access event per successful check. The experiment verifies that the counter remains queryable after lab.access.health is disabled.

Metrics and logs are not interchangeable. A metric preserves count and trend; an error log preserves diagnostic context. The design intentionally keeps both where each is strongest.

Measure before and after

Both variants received the same bounded workload. The captured container logs produced:

SignalBaselineHealth logger off
Captured bytes933,74451,220
Successful health access events2,0010
Successful business access events100100
Error events1010
Byte reduction94.51%

The extra baseline health event is the readiness request used before the measured loop. It is included rather than silently adjusted away.

The result demonstrates selectivity: every business access event and every injected error remained, while routine health access events disappeared. It does not prove the same percentage for another service because JSON shape, stack traces, sidecars, probe intervals, and traffic mix differ.

Make the cost projection explicit

The projection uses the measured byte difference per health request:

avoided bytes/request
  = (baseline bytes - filtered bytes) / 2,000

monthly probe requests
  = 30 days × 24 hours × 60 minutes × 12 probes/minute

For one replica, one probe every five seconds, and an illustrative ingestion rate of $0.50/GiB, the model yields:

Projection input or outputValue
Avoided ingestion0.213 GiB/month
Illustrative avoided ingestion charge$0.1065/month

That tiny number is an important result. For one small service, this optimization barely affects the bill. The value can grow across many replicas, several probes, verbose access formats, retention tiers, and indexing charges, but it must be calculated from the actual platform. Do not present an illustrative ingestion rate as a cloud invoice.

Guardrails for production

I would ship this only with the following conditions:

  • Match a normalized route or dedicated filter-chain attribute, not message text.
  • Check status first so 5xx events can never enter the suppressible logger.
  • Emit 5xx events at ERROR and test the configured threshold.
  • Preserve probe counts and failures as metrics with alerts.
  • Keep business access logging and application exceptions independently configurable.
  • Validate request IDs and never log tokens, credentials, or bodies by default.
  • Re-enable or sample the health logger temporarily when incident analysis requires it.
  • Compare actual ingest, index, retention, and query costs before claiming savings.

If log pressure comes from business traffic rather than probes, use deliberate sampling, aggregation, shorter retention, or narrower structured fields. Do not hide all successful requests without deciding what audit and latency evidence the service still needs.

The implementation choices follow the Spring Boot logging reference, Spring Boot Actuator metrics reference, and Logback filter documentation.