DEV Community

Cover image for **5 Essential Java Observability Techniques for Production Applications: Tracing, Logging, Metrics**
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

**5 Essential Java Observability Techniques for Production Applications: Tracing, Logging, Metrics**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I run a Java application, I often think of it like driving a car at night. The code is the engine, humming along. But without headlights, dashboard gauges, and a map, I have no idea how fast I'm going, if the engine is overheating, or where I am when something goes wrong. Observability gives me those tools. It's not just checking if the car starts; it's about understanding every aspect of its journey in real-time.

In production, things are complex. A single user request might touch a dozen different services. A slow response could be due to a database, a microservice, or a third-party API. My job is to find out which one, and why. I’ve moved beyond just printing lines to a log file. I need a cohesive strategy to see inside the running system.

Let’s talk about distributed tracing first. Imagine a request coming into an online store to place an order. It goes to the order service, which talks to the inventory service, which then calls the payment service. If that payment is slow, how do I know? Traditional logs would show three separate, unconnected events. Tracing links them together into a single story.

I use a standard called OpenTelemetry for this. It provides libraries that help me instrument my code. While many tools can automatically add tracing, sometimes I need to be explicit about what I want to track. Here’s a basic example of creating a trace span manually.

import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Scope;

// I get a Tracer instance, usually injected or from a global util
Tracer tracer = openTelemetry.getTracer("com.example.orderservice");

// I start a new span for a specific operation
Span span = tracer.spanBuilder("processPayment").startSpan();

try {
    // I make this span the active one for the current thread of execution
    Scope scope = span.makeCurrent();

    // I can attach useful details to the span
    span.setAttribute("payment.amount", 99.99);
    span.setAttribute("payment.method", "credit_card");

    // I can record events within the span's lifetime
    span.addEvent("Calling payment gateway");

    // Here, the actual business logic happens
    boolean success = paymentClient.charge(amount);

    span.addEvent("Payment gateway responded");
    span.setAttribute("payment.success", success);

} catch (Exception e) {
    // If something goes wrong, I record that too
    span.recordException(e);
    throw e;
} finally {
    // It's crucial to end the span so it can be sent for analysis
    span.end();
}
Enter fullscreen mode Exit fullscreen mode

When I run this, each span has a unique ID. More importantly, when the order service calls the inventory service, OpenTelemetry passes a special trace ID and parent span ID along with the request. The receiving service then creates its own span as a child. This builds a visual tree of the entire transaction, called a trace. I can see the exact duration of each step and identify the slow one immediately.

The second technique changes how I think about logs. For years, I wrote logs like this: logger.info("Processing order 12345 for customer 67890");. To find all orders for that customer, I’d have to use complex text searches or regular expressions. It’s messy and inefficient. Structured logging fixes this.

Instead of a sentence, I log an event with clearly labeled fields. Think of it like a database row or a JSON object. My log management system can then index these fields, making searches fast and powerful.

Here’s how I implement it. I use a common logging facade like SLF4J with a backend that supports structured output, such as Logback with the Logstash encoder.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.logstash.logback.argument.StructuredArguments;

public class OrderProcessor {
    private static final Logger logger = LoggerFactory.getLogger(OrderProcessor.class);

    public void fulfillOrder(Order order, String warehouseCode) {
        // Traditional, unstructured way (avoid this)
        // logger.info("Fulfilling order " + order.getId() + " from warehouse " + warehouseCode);

        // Structured logging way
        logger.info("Order fulfillment initiated",
            StructuredArguments.keyValue("order_id", order.getId()),
            StructuredArguments.keyValue("customer_id", order.getCustomerId()),
            StructuredArguments.keyValue("total_amount", order.getTotal()),
            StructuredArguments.keyValue("warehouse", warehouseCode),
            StructuredArguments.keyValue("event_type", "fulfillment_start")
        );

        try {
            // ... business logic to pick and pack the order ...

            logger.info("Order fulfillment completed",
                StructuredArguments.keyValue("order_id", order.getId()),
                StructuredArguments.keyValue("event_type", "fulfillment_success"),
                StructuredArguments.keyValue("processing_time_ms", 450) // calculated value
            );
        } catch (OutOfStockException e) {
            logger.error("Order fulfillment failed - inventory shortage",
                StructuredArguments.keyValue("order_id", order.getId()),
                StructuredArguments.keyValue("event_type", "fulfillment_failure"),
                StructuredArguments.keyValue("reason", "out_of_stock"),
                StructuredArguments.keyValue("sku", e.getMissingSku())
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When this runs, the log isn’t just a line of text. It’s emitted as a JSON object. This might look like:

{
  "@timestamp": "2023-10-27T10:15:30.123Z",
  "message": "Order fulfillment initiated",
  "logger": "com.example.OrderProcessor",
  "level": "INFO",
  "order_id": "ORD-78910",
  "customer_id": "CUST-11223",
  "total_amount": 149.99,
  "warehouse": "WH-EAST",
  "event_type": "fulfillment_start"
}
Enter fullscreen mode Exit fullscreen mode

Now, in my log dashboard, I can easily run a query like customer_id:"CUST-11223" AND event_type:"fulfillment_failure" to find all failed orders for that specific customer. I can create charts based on the processing_time_ms field. It turns logs from a textual diary into queryable operational data.

The third pillar is metrics. While traces show me individual requests and logs show me discrete events, metrics give me the summarized, numerical health of the system over time. How many requests per minute? What’s the 95th percentile response time? What’s the error rate? I use a library called Micrometer to handle this. It’s a metrics facade, similar to how SLF4J works for logging, that works with many monitoring systems like Prometheus, Datadog, or New Relic.

I track two types of metrics: infrastructure metrics (like CPU, memory) and business metrics. Business metrics are particularly valuable because they connect technical performance to business outcomes. Let me show you how I instrument an order checkout process.

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;

@Component
public class CheckoutService {

    private final MeterRegistry registry;
    private final Counter successfulCheckouts;
    private final Counter failedCheckouts;
    private final DistributionSummary orderValueSummary;
    private final Timer checkoutDurationTimer;

    public CheckoutService(MeterRegistry registry) {
        this.registry = registry;

        // I create a counter to track successful and failed checkouts
        this.successfulCheckouts = Counter.builder("checkout.attempts")
            .description("Total number of successful order checkouts")
            .tag("status", "success")
            .register(registry);

        this.failedCheckouts = Counter.builder("checkout.attempts")
            .description("Total number of failed order checkouts")
            .tag("status", "failure")
            .register(registry);

        // I create a distribution summary to track the value of orders
        // This lets me see average order value, max, min, etc.
        this.orderValueSummary = DistributionSummary.builder("checkout.order_value")
            .description("Monetary value of checked out orders")
            .baseUnit("USD")
            .register(registry);

        // I create a timer to measure how long the checkout process takes
        this.checkoutDurationTimer = Timer.builder("checkout.processing.time")
            .description("Time spent processing a checkout")
            .publishPercentiles(0.5, 0.95, 0.99) // Median, 95th, and 99th percentiles
            .register(registry);
    }

    public Receipt processCheckout(ShoppingCart cart, PaymentInfo payment) {
        // I use Timer.Sample to record the duration of this whole block
        Timer.Sample sample = Timer.start(registry);

        try {
            // Validate cart, process payment, create order...
            BigDecimal total = calculateTotal(cart);

            // Record the order value as a business metric
            orderValueSummary.record(total.doubleValue());

            PaymentResult result = paymentGateway.charge(payment, total);

            if (result.isSuccess()) {
                Receipt receipt = generateReceipt(cart, result);

                // Increment the success counter
                successfulCheckouts.increment();

                return receipt;
            } else {
                // Increment the failure counter
                failedCheckouts.increment();
                throw new PaymentFailedException(result.getError());
            }
        } finally {
            // I stop the timer regardless of success or failure
            sample.stop(checkoutDurationTimer);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With this in place, I can set up a dashboard that shows my checkout conversion rate (successful / total attempts), monitor if the average order value is dropping, and get alerted if the 99th percentile checkout time goes above 5 seconds, meaning some users are having a very bad experience. Metrics turn subjective feelings about "slowness" into objective, actionable numbers.

The fourth technique is about bringing all this data together. In a production environment, I don’t have one application; I have many. They run on multiple servers, in containers, across different data centers. If each service writes its logs and metrics to its own local disk, I’d have to log into each machine to troubleshoot. That’s impossible at scale. I need centralized aggregation.

This means all logs, traces, and metrics are sent to a central system. For logs, I use a lightweight agent on each server (like Filebeat or Fluentd) that reads the log files and forwards them to a central store like Elasticsearch. For metrics, my monitoring agent (like the Prometheus node exporter or the Micrometer registry) sends data to a time-series database. For traces, the OpenTelemetry collector sends them to a backend like Jaeger.

The application configuration enables this. Here’s a typical logback-spring.xml configuration I might use in a Spring Boot application to send logs directly to a Logstash server.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- I define an appender that sends logs over the network -->
    <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
        <!-- Destination is my central Logstash server -->
        <destination>log-aggregator.production.mycompany.com:5000</destination>

        <!-- I use an encoder that outputs JSON -->
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <!-- I add custom, static fields to every log event for easy filtering -->
            <customFields>{"service_name":"order-service","pod_name":"${HOSTNAME}","environment":"prod-us-east"}</customFields>
        </encoder>

        <!-- Keep the connection alive -->
        <keepAliveDuration>5 minutes</keepAliveDuration>
    </appender>

    <root level="info">
        <appender-ref ref="LOGSTASH" />
    </root>
</configuration>
Enter fullscreen mode Exit fullscreen mode

The key outcome is correlation. When I get an alert about high error rates in the payment service, I can go to my observability platform. I click on the spike in the error metric. I see the related trace IDs of the failed requests. I click on a trace and see the exact path through the services. I then pull up the structured logs from every service involved in that trace, using the trace ID as a common field. I have the complete picture in one place: the metric that told me something is wrong, the trace that showed me where it was wrong, and the logs that tell me why it was wrong.

Finally, the fifth technique is about proactive health management. My application needs to tell the platform running it if it’s healthy. In Kubernetes or cloud platforms, this is used to decide if a container should receive traffic or be restarted. A simple "I'm running" check isn't enough. I need deep health checks that verify internal state and external dependencies.

In Spring Boot, I use the Actuator health endpoint. Out of the box, it can check datasource connectivity. But I must extend it to check the things that matter for my service to be truly "ready".

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class ServiceDependencyHealthIndicator implements HealthIndicator {

    private final InventoryClient inventoryClient;
    private final EmailService emailService;
    private final CacheProvider cache;

    // I use constructor injection to get my critical dependencies
    public ServiceDependencyHealthIndicator(InventoryClient inventoryClient, 
                                            EmailService emailService, 
                                            CacheProvider cache) {
        this.inventoryClient = inventoryClient;
        this.emailService = emailService;
        this.cache = cache;
    }

    @Override
    public Health health() {
        // I start assuming the service is up
        Health.Builder healthBuilder = Health.up();
        boolean allCriticalHealthy = true;

        // 1. Check Inventory Service (Critical)
        try {
            // A simple ping or a lightweight read operation
            inventoryClient.ping();
            healthBuilder.withDetail("inventory_service", Map.of("status", "UP", "latency_ms", 12));
        } catch (Exception e) {
            healthBuilder.withDetail("inventory_service", Map.of("status", "DOWN", "error", e.getMessage()));
            allCriticalHealthy = false; // This is a critical failure
        }

        // 2. Check Cache Connection (Critical)
        try {
            cache.ping();
            healthBuilder.withDetail("cache", Map.of("status", "UP"));
        } catch (Exception e) {
            healthBuilder.withDetail("cache", Map.of("status", "DOWN", "error", e.getMessage()));
            allCriticalHealthy = false;
        }

        // 3. Check Email Service (Non-Critical - service can be DEGRADED but still UP)
        try {
            emailService.ping();
            healthBuilder.withDetail("email_service", Map.of("status", "UP"));
        } catch (Exception e) {
            // Email is down, but orders can still be placed.
            // I mark the overall status as DEGRADED, not DOWN.
            healthBuilder.withDetail("email_service", Map.of("status", "DOWN", "error", e.getMessage()));
            healthBuilder.status(new Status("DEGRADED", "Non-critical dependency failure"));
        }

        // If any critical dependency failed, the overall status must be DOWN
        if (!allCriticalHealthy) {
            healthBuilder.down();
        }

        return healthBuilder.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

When I deploy this, my platform (like Kubernetes) regularly calls the /actuator/health endpoint. If it returns a status of DOWN, Kubernetes will stop sending new traffic to that pod and might restart it. If it returns DEGRADED, I might get an alert, but the service continues to operate. This automated health management is crucial for resilience.

Bringing it all together, these five techniques form a safety net and a magnifying glass for my production applications. Distributed tracing maps the journey. Structured logging provides the annotated diary. Metrics give the vital signs. Centralized aggregation brings the view into a single room. Health checks enable the system to heal itself.

The goal isn’t to add complexity. It’s the opposite. It’s to reduce the time it takes me to answer questions. Why was this user’s request slow? What caused this spike in errors? Is the new deployment performing better than the old one? With a proper observability setup, I can answer these questions in minutes, not hours. I shift from reactive firefighting to proactive management and confident deployment. It transforms the black box of production into a transparent, understandable system.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)