The most surprising thing about monitoring a monolith is that the very simplicity you sought by putting everything in one place becomes the biggest obstacle to understanding what’s actually going on.

Imagine a single, massive Java application handling user requests, processing payments, and sending emails. Normally, you’d have separate services, each with its own logs, metrics, and traces. But with a monolith, all that activity is interleaved within a single process.

Let’s see this in action. Here’s a snippet of logs from a hypothetical monolith handling a user login:

2023-10-27 10:00:01 INFO [http-nio-8080-exec-5] com.example.LoginController - Received login request for user: alice
2023-10-27 10:00:01 DEBUG [http-nio-8080-exec-5] com.example.UserService - Authenticating user alice
2023-10-27 10:00:01 DEBUG [http-nio-8080-exec-5] com.example.DatabaseService - Executing SQL: SELECT * FROM users WHERE username = 'alice'
2023-10-27 10:00:02 INFO [http-nio-8080-exec-5] com.example.EmailService - Sending welcome email to alice@example.com
2023-10-27 10:00:03 INFO [http-nio-8080-exec-5] com.example.LoginController - Login successful for user: alice

Notice how the http-nio-8080-exec-5 thread is churning through different logical components: LoginController, UserService, DatabaseService, and EmailService. In a microservices world, these would be distinct processes, and their logs, metrics, and traces would be neatly separated. Here, they’re all mashed together.

The core problem this addresses is observability. How do you know which part of your single application is slow, or error-prone, or consuming too many resources, when it’s all one big heap? You need to disentangle the logical flows within the physical process.

Logs: The foundation. You need structured logging to distinguish between different operations. Instead of just a timestamp and message, you’ll want fields like operation, userId, component, and duration.

{
  "timestamp": "2023-10-27T10:00:01Z",
  "level": "INFO",
  "thread": "http-nio-8080-exec-5",
  "logger": "com.example.LoginController",
  "message": "Received login request",
  "operation": "login",
  "userId": "alice"
}
{
  "timestamp": "2023-10-27T10:00:02Z",
  "level": "INFO",
  "thread": "http-nio-8080-exec-5",
  "logger": "com.example.EmailService",
  "message": "Sending welcome email",
  "operation": "login",
  "userId": "alice",
  "email": "alice@example.com",
  "duration_ms": 1000
}

Metrics: Aggregated numerical data. For a monolith, you’re often looking at metrics per component or per operation rather than per service. Think http_requests_total{path="/login", component="LoginController"}, database_query_duration_seconds{operation="authenticateUser", component="DatabaseService"}, or email_send_failures_total{type="welcome", component="EmailService"}. You’ll likely instrument your code to increment counters and record durations for key logical operations.

Traces: The golden thread. This is where you connect the dots. Distributed tracing concepts, like those in OpenTelemetry, are crucial. You’ll need to propagate context (a trace ID and span ID) across method calls within the monolith. A request comes in, a root span is created. When LoginController calls UserService, a new child span is created, linked to the parent. This allows you to visualize the entire lifecycle of a request, even as it hops between logical modules in the same process.

Here’s a conceptual trace for the login:

  • Span 1 (Root): login_request (traceId: abc123, spanId: def456)
    • Span 1.1 (Child): authenticate_user (traceId: abc123, parentSpanId: def456)
      • Span 1.1.1 (Grandchild): db_query (traceId: abc123, parentSpanId: ghi789) - duration: 50ms
    • Span 1.2 (Child): send_welcome_email (traceId: abc123, parentSpanId: def456) - duration: 1000ms

The key to making this work is context propagation. Libraries like ThreadLocal in Java or similar mechanisms in other languages are used to pass trace IDs and span IDs down method call chains. When you execute a method that might trigger a database query or an external API call (even if it’s just another module in the monolith), you ensure the current trace context is available.

The one thing most people don’t realize is that you often need to instrument between logical components within the monolith just as rigorously as you would between microservices. This means explicitly creating spans for method calls that represent distinct units of work, rather than relying on automatic instrumentation that might only see the single process boundary. For example, a call from LoginController.handleRequest() to UserService.authenticate(username) should likely have its own span, even though it’s a direct Java method call.

The next problem you’ll likely encounter is understanding how different threads within the monolith interact, especially when dealing with asynchronous operations or thread pools.

Want structured learning?

Take the full Monolith course →