← Home
Observability

Top 8 Senior Interview Questions

Structured Logging · MDC · Distributed Tracing · Micrometer · SLIs/SLOs · OpenTelemetry · Debugging in Production

Questions

  1. The three pillars of observability — logs, metrics, traces
  2. Structured logging and MDC — correlation IDs across threads
  3. Distributed tracing — how trace context propagates across services
  4. Micrometer metrics — types, percentiles, and the RED method
  5. SLIs, SLOs, and error budgets
  6. Debugging a latency spike in production
  7. Health checks — liveness vs readiness, and how to implement them correctly
  8. OpenTelemetry — zero-code instrumentation and the Collector pattern
Q1 · Foundations
What are the three pillars of observability and how do they relate to each other?

Direct answer

The three pillars are logs, metrics, and traces. They answer different questions: metrics tell you something is wrong, traces tell you where in the call graph the problem is, and logs tell you exactly what happened at that point. They're most powerful when connected — a metric alert leads you to a trace, and the trace leads you to the relevant log lines.

What each pillar is for

Logs are discrete events with context. A log line captures what happened at a specific moment. Structured logging (JSON) makes them machine-parseable and searchable. Aggregated in ELK (Elasticsearch + Logstash + Kibana) or Grafana Loki. Strength: rich detail. Weakness: high volume, expensive to store and query at scale.

Metrics are numerical measurements over time — counters, gauges, histograms. Low storage cost, fast to query. Used for dashboards, alerting, SLO tracking. Prometheus + Grafana is the standard stack. Strength: cheap, fast, great for alerting. Weakness: aggregated — you lose individual request detail.

Traces represent a single request's journey across services as a tree of spans. Each span captures start time, duration, and metadata for one operation. Used to understand latency distribution and find bottlenecks. Jaeger, Tempo, Zipkin. Strength: end-to-end visibility. Weakness: high data volume — requires sampling.

The debugging workflow

Metrics alert you there's a problem → P99 latency is 800ms, baseline is 50ms. Traces locate the problem → find slow traces from that window, identify which span is responsible (DB query, downstream call). Logs explain the problem → look at log lines from that specific span — slow query text, error messages, context.

Without traces, you're correlating log timestamps across multiple services manually — error-prone and time-consuming. Teams that skip traces regret it the first time they debug a cross-service latency issue.

The production gotcha

Many teams have logs and metrics but no traces, often because traces are "the third thing" and get deprioritised. Adding traces retroactively to a large microservices system is hard: every HTTP client, Kafka producer/consumer, and DB connection needs instrumentation. The right time to add tracing is at the start of the project, not after the first production incident that requires it.

Interview drill-down — expect these follow-ups:
L1 → "You have all three pillars — what's the difference between a metric alert and a log alert?"
Metric alerts are based on aggregated numerical thresholds — fast to evaluate, low cardinality, great for "something is wrong" detection. Log alerts search free-text or structured fields — higher cardinality, slower to evaluate, better for specific event patterns ("user account locked more than 10 times"). Use metrics for alerting at scale; use log queries for the follow-up investigation. Alerting on raw log patterns at high volume is expensive and slow.
L2 → "Traces require sampling — how do you decide what sample rate to use?"
At 1,000 req/s with 20 spans/request: 100% sampling = 20,000 spans/sec → ~72M spans/hour → very expensive. Start at 10% head-based sampling. For services under 100 req/s, 100% sampling is usually affordable. The key insight: use tail-based sampling instead — sample all error traces and all traces above your P99 threshold, plus a fraction of normal traces. This gives you 100% coverage where it matters most without the cost of full sampling.
L3 → "What is the 'observability tax' and how do you manage it?"
Instrumentation overhead: OTel agent adds ~3–5% latency overhead on the hot path. Log serialization to JSON is slower than plaintext. Exporting telemetry consumes CPU and network. Managing it: profile the agent overhead with and without in load tests. Use asynchronous batch exporters (never synchronous). Sample aggressively. Set log levels to INFO in production, DEBUG only on demand via Logback's JMX support or Spring Boot's log level actuator endpoint (/actuator/loggers/{logger}).
Trap answer to avoid: "If I have logs, I don't need traces." — Logs tell you what happened in one service. Traces tell you where in a multi-service call graph the problem occurred. Without traces, correlating logs across 10 services by timestamp and guessing which requests correspond to each other is a nightmare. At 1 service, logs suffice. At 3+ services, traces are essential.
Q2 · Logging
What is structured logging and how do you implement correlation IDs with MDC in Spring Boot?

Direct answer

Structured logging means emitting log lines as key-value pairs (JSON) rather than unstructured strings. Every field is individually searchable in your log aggregator. MDC (Mapped Diagnostic Context) is a thread-local map that Logback/Log4j2 automatically appends to every log statement — set a traceId once on request entry and it appears in every log line for that request without any per-call code.

Implementation

// Unstructured — a string, unparseable:
log.info("Order {} processed by user {} in {}ms", orderId, userId, duration);

// Structured — every field is independently queryable:
log.info("Order processed",
    kv("orderId", orderId),
    kv("userId", userId),
    kv("durationMs", duration));
// Output: {"level":"INFO","message":"Order processed","orderId":"ORD-123",
//          "userId":"U-456","durationMs":42,"traceId":"abc123",...}

MDC filter — runs on every incoming HTTP request:

@Component
class TraceIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest req, HttpServletResponse res,
            FilterChain chain) throws ServletException, IOException {

        String traceId = Optional
            .ofNullable(req.getHeader("X-Trace-Id"))    // propagate from upstream
            .orElse(UUID.randomUUID().toString());

        MDC.put("traceId", traceId);
        MDC.put("userId", extractUserId(req));
        res.setHeader("X-Trace-Id", traceId);         // echo back to caller
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();                                // always clean up
        }
    }
}

Also propagate traceId on outgoing HTTP calls so downstream services include it in their logs:

// RestTemplate interceptor — adds X-Trace-Id to every outgoing call
restTemplate.setInterceptors(List.of((request, body, execution) -> {
    String traceId = MDC.get("traceId");
    if (traceId != null) request.getHeaders().set("X-Trace-Id", traceId);
    return execution.execute(request, body);
}));

The production gotcha — MDC and async

MDC is thread-local. When @Async or CompletableFuture spawns a new thread, that thread starts with an empty MDC — your traceId disappears from all async log lines. Fix: copy MDC in a TaskDecorator:

@Bean
public Executor asyncExecutor() {
    ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
    exec.setTaskDecorator(runnable -> {
        Map<String, String> mdc = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (mdc != null) MDC.setContextMap(mdc);
                runnable.run();
            } finally { MDC.clear(); }
        };
    });
    exec.initialize();
    return exec;
}

The decorator captures the MDC snapshot on the submitting thread, then restores it in the executing thread before the task runs.

Interview drill-down — expect these follow-ups:
L1 → "MDC is thread-local. How does it work with virtual threads (Java 21)?"
Virtual threads still use ThreadLocal under the hood (Logback/SLF4J's MDC uses ThreadLocal). But with millions of virtual threads, the per-thread ThreadLocal copy multiplies memory pressure significantly. Java 21+ introduces ScopedValues as the preferred replacement — they're immutable, inherited by child scopes, and GC'd when the scope exits. Logback hasn't fully migrated to ScopedValues yet; watch for framework updates. For now, the TaskDecorator pattern still works correctly with virtual threads.
L2 → "How do you propagate MDC context across Reactor (WebFlux) chains?"
Reactor doesn't use thread locals for context — it has its own Reactor Context (a key-value store that propagates downstream in the pipeline). Use Micrometer's ContextRegistry to bridge between MDC and Reactor Context: it automatically copies MDC values into the Reactor Context on subscription and restores them on each operator execution. Alternatively: contextWrite(ctx -> ctx.put("traceId", MDC.get("traceId"))) explicitly. This is a common pitfall when mixing Spring MVC and WebFlux.
L3 → "Sensitive data in logs — PII. What's the Java implementation to mask it systematically?"
Two approaches: (1) Logback's MessageConverter — subclass ClassicConverter to intercept the formatted message and apply regex masking on known patterns (card numbers: \b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b). Register in logback.xml. (2) Jackson mixin or custom serializer: annotate DTO fields with @JsonSerialize(using=MaskingSerializer.class). The serializer returns "***" for sensitive fields. Approach 2 is safer — it prevents the data from ever entering the log string, rather than post-processing it after it might already be formatted.
Trap answer to avoid: "I add the traceId to every log.info() call manually." — This doesn't scale and is easily forgotten. The correct approach: MDC set once per request in a filter, Logback pattern references %X{traceId}. The traceId appears automatically in every log line without any per-call code. If you're passing traceId as a parameter through your entire call stack, you've missed MDC entirely.
Q3 · Distributed Tracing
Explain how distributed tracing works. How does a trace span propagate across microservices?

Direct answer

A trace represents a single request end-to-end across multiple services, identified by a unique traceId. Each operation within the trace is a span — it has a spanId, a parentSpanId, start time, and duration. Spans form a tree. Context propagation means every service receives the traceId and spanId from its caller via HTTP headers, creates a child span, does its work, and passes the context to its own downstream calls.

How propagation works

The W3C traceparent header is the standard format: 00-{traceId}-{parentSpanId}-{flags}. Each service reads this header, extracts the traceId and parentSpanId, creates a new child span with the parentSpanId as its parent, and injects its own spanId into outgoing calls.

// Spring Boot 3 with Micrometer Tracing — automatic context propagation
// application.yml
management:
  tracing:
    sampling:
      probability: 0.1       # sample 10% of requests
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans

// pom.xml
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>

With these dependencies, Spring Boot auto-instruments HTTP requests (both inbound and outbound via RestTemplate/WebClient), Kafka consumers/producers, and database calls. The traceId is also injected into MDC automatically — every log line gets the traceId without any extra code.

For custom spans around significant operations:

@Autowired Tracer tracer;

public Order processOrder(CreateOrderRequest req) {
    Span span = tracer.nextSpan().name("process-order").start();
    try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
        span.tag("order.type", req.getType());
        return orderService.create(req);
    } catch (Exception e) {
        span.error(e);
        throw e;
    } finally {
        span.end();
    }
}

Sampling strategies

Recording every span for every request is expensive — a service handling 10,000 req/s with 20 spans per request generates 200,000 spans/second. Two sampling approaches:

Head-based sampling: decision made at the first service (e.g. sample 10% of requests). Cheap but you might miss the rare slow request in the 90% unsampled.

Tail-based sampling: decision made after the trace completes, prioritising slow and errored traces. Catches the outliers that matter most for debugging. Requires a collector that buffers completed traces before making the sampling decision (OpenTelemetry Collector supports this).

The production gotcha

Micrometer Tracing replaced Spring Cloud Sleuth in Spring Boot 3. If your team is upgrading from Boot 2 to Boot 3, the migration is non-trivial: remove spring-cloud-sleuth, add micrometer-tracing-bridge-brave or the OTel bridge. Trace context is also lost across @Async boundaries unless you configure a ContextPropagatingTaskDecorator — the same problem as MDC, but for trace context.

Interview drill-down — expect these follow-ups:
L1 → "Trace context is lost across @Async. How does ContextPropagatingTaskDecorator differ from the MDC TaskDecorator?"
The MDC TaskDecorator copies only the MDC map. ContextPropagatingTaskDecorator (from Micrometer Tracing) copies both the MDC and the tracing context (active span, baggage). If you use only the MDC decorator, the traceId appears in logs but there's no parent-child span relationship — the async task's spans appear disconnected from the original request trace in your tracing tool. Use ContextPropagatingTaskDecorator to propagate both and maintain the correct trace tree.
L2 → "Head-based vs tail-based sampling — what's the implementation complexity difference?"
Head-based: decided at the first service, propagated via sampled bit in traceparent header. Stateless, extremely cheap, simple. Tail-based: the collector must buffer every span from every service for a trace until the trace is complete, then make the sampling decision. Requires the collector to hold spans in memory for up to 30s waiting for the trace to complete. Memory pressure and complexity are significant. But for debugging rare events (errors, slow traces) it's invaluable — head-based sampling often misses the 0.1% of requests that are actually broken.
L3 → "Micrometer Tracing replaced Spring Cloud Sleuth. What breaks if you try to use Sleuth with Spring Boot 3?"
Spring Cloud Sleuth 3.x targets Spring Boot 2.x. It uses its own BeanDefinitions that conflict with Spring Boot 3's auto-configuration. The result is typically NoSuchBeanDefinitionException for Tracer or ambiguous bean conflicts. Migration path: remove spring-cloud-sleuth dependency entirely, add micrometer-tracing-bridge-brave and zipkin-reporter-brave. The API changes: Span/Tracer interfaces are now from io.micrometer.tracing, not io.zipkin.brave. @NewSpan becomes @NewSpan from io.micrometer.observation.annotation.
Trap answer to avoid: "I use 100% trace sampling in production for full visibility." — At 1,000 req/s with 20 spans/request = 20,000 spans/second = 72M spans/hour. Your tracing backend and storage will be overwhelmed. 100% sampling is only appropriate for very-low-traffic services or local development. Production standard: 1–10% head-based + 100% for errors and slow traces.
Q4 · Metrics
How do you expose and use metrics in Spring Boot? What metric types exist and when do you use each?

Direct answer

Micrometer is Spring Boot's metrics facade — one API, pluggable backends (Prometheus, Datadog, CloudWatch). It ships with spring-boot-starter-actuator. Four core metric types cover almost all use cases: Counter, Gauge, Timer, and DistributionSummary. The most important decision beyond choosing a type is cardinality: every tag combination creates a separate time series.

The four types

Counter — monotonically increasing. For counting events. Query as a rate: rate(orders_processed_total[5m]).

Counter.builder("orders.processed")
    .tag("status", "SUCCESS")
    .description("Orders processed successfully")
    .register(registry)
    .increment();

Gauge — current value, can go up or down. For measuring instantaneous state:

Gauge.builder("order.queue.size", orderQueue, Queue::size)
    .description("Orders waiting to be processed")
    .register(registry);

Timer — measures duration AND count. Auto-computes count, total time, max, and optionally percentiles. The most useful type for latency:

Timer timer = Timer.builder("payment.charge")
    .tag("provider", "stripe")
    .publishPercentileHistogram(true)   // for aggregatable fleet-wide percentiles
    .register(registry);

Timer.Sample sample = Timer.start(registry);
try {
    return stripeClient.charge(req);
} finally {
    sample.stop(timer);
}

DistributionSummary — like Timer but for non-time values (e.g. request payload size in bytes).

The RED method — three metrics every service must have

Rate: requests per second. Errors: error rate (%). Duration: latency percentiles (P50, P95, P99). Start here before adding domain-specific metrics. Spring Boot auto-instruments HTTP requests, JVM heap/GC, HikariCP pool size/active connections, Kafka consumer lag, and Spring Cache hits/misses — all without writing any code.

The production gotcha — aggregatable vs non-aggregatable percentiles

publishPercentiles(0.99) computes a pre-aggregated, non-aggregatable percentile client-side. You cannot average or sum P99 values across instances — the average of 5 P99s is not the fleet P99. It produces a per-pod P99 only.

publishPercentileHistogram(true) emits bucket counts (_bucket metrics). Prometheus histogram_quantile() recomputes the percentile from the combined buckets of all pods — this is the fleet-wide P99. Always use histogram for production dashboards:

# Prometheus query — fleet-wide P99 across all pods
histogram_quantile(0.99,
  sum(rate(payment_charge_seconds_bucket[5m])) by (le)
)
Interview drill-down — expect these follow-ups:
L1 → "publishPercentileHistogram creates many _bucket metrics. How many and what's the storage impact?"
Micrometer uses a predefined set of bucket boundaries (about 270 buckets, based on Prometheus' default histogram bucket scheme). Each bucket creates a separate time series in Prometheus. For a timer with 3 tags (status, provider, endpoint) with modest cardinality: 270 buckets × N label combinations = potentially thousands of time series per timer. Watch out for high-cardinality tags combined with histogram. Micrometer's default bucket scheme is designed to cover ~1μs to ~15min with reasonable precision.
L2 → "histogram_quantile(0.99, ...) — what's the accuracy vs publishPercentiles(0.99) tradeoff?"
histogram_quantile is an approximation based on bucket boundaries — accuracy depends on whether the actual value falls within the bucket's range. For typical latency distributions (mostly clustered, occasional outliers), the error is usually within a few percent. publishPercentiles(0.99) is the exact P99 for that one pod — perfect accuracy, but non-aggregatable across pods. For a 10-pod fleet, you want histogram_quantile because it gives you the fleet P99. The ±few% approximation error is an acceptable trade for the aggregatability.
L3 → "How do you instrument a Kafka consumer with the RED method?"
Rate: Counter for messages consumed (tag: topic, partition, consumer_group). Error: Counter for processing failures (tag: topic, error_type). Duration: Timer for processing time per message (tag: topic). Also critical for Kafka: consumer lag as a Gauge (kafka.consumer.fetch.manager.records.lag from Kafka's JMX metrics, auto-exposed by Micrometer's KafkaClientMetrics). Lag is the most important Kafka consumer health metric — it tells you how far behind the consumer is from the producer.
Trap answer to avoid: "I use Gauge for request latency." — Gauge measures instantaneous current value. Latency isn't a current state — it's a distribution over many requests. A Gauge would only show the last measurement, losing all statistical information. Timer is correct: it accumulates count, sum, max, and histogram buckets from every request, enabling rate() and histogram_quantile() queries.
Q5 · SLIs and SLOs
What are SLIs, SLOs, and error budgets? How do you define them and use them operationally?

Direct answer

SLI (Service Level Indicator) is a measurement — a specific metric that captures how well you're serving users. SLO (Service Level Objective) is a target for that SLI — a promise you make to yourself about reliability. The error budget is the difference between perfect and your SLO, expressed as the amount of failure you're allowed. Burning the error budget too fast means slowing down deployments. Having budget to spare means you can move fast and take risks.

Defining good SLIs

Good SLIs measure what users actually experience, not internal service health:

ServiceSLISLO target
API% requests completing in <200ms99% over 30 days
API% requests returning non-5xx99.9% over 30 days
Background job% jobs completing within SLA window99.5% over 7 days
Kafka consumerConsumer lag < 30s99% of time

Error budget: 99.9% availability over 30 days = 0.1% allowed failure = ~43.2 minutes/month of downtime. This is the team's budget to spend on failures from any cause: bad deploys, infrastructure outages, dependency failures.

# Prometheus SLO burn rate alert (multi-window, from Google SRE book)
# Alert when you're burning the monthly budget too fast

- alert: HighErrorBudgetBurn
  expr: |
    (
      job:slo_errors:ratio_rate1h{job="payment-service"} > (14.4 * 0.001)
      and
      job:slo_errors:ratio_rate5m{job="payment-service"} > (14.4 * 0.001)
    )
  for: 2m
  annotations:
    summary: "Burning error budget at 14.4x rate — will exhaust in 5 days"

Using error budgets operationally

Error budget isn't just a monitoring concept — it drives engineering decisions. If you've consumed 80% of your monthly budget in week 1: freeze non-critical feature deployments, focus the team on reliability improvements, investigate what caused the burn. If you have plenty of budget: safe to ship fast, try risky experiments, accept more deployment risk.

This framing removes "reliability vs velocity" arguments: the error budget is objective. When it's gone, velocity stops. When it's full, move fast. Both sides of the tradeoff are satisfied.

The production gotcha

Setting the SLO too tight on day one. If you set 99.99% and your service currently runs at 99.7%, you're immediately in a permanent incident. Good starting approach: measure your actual 30-day performance, set your initial SLO slightly below current performance to establish a baseline, then tighten quarterly as reliability improves. Don't set aspirational SLOs — set realistic ones you can actually meet.

Interview drill-down — expect these follow-ups:
L1 → "Error budget alert fired — 14.4x burn rate. What specifically does '14.4x' mean?"
14.4x means you're burning your monthly error budget 14.4 times faster than sustainable. At that rate: monthly_budget / 14.4 = you'll exhaust the budget in 30/14.4 ≈ 2 days. The 14.4 threshold comes from the Google SRE Workbook: at 14.4x burn rate with a 1-hour window, you'll exhaust a 30-day budget in ~5 days. Alerting here gives you enough time to respond before the SLO is breached, without being so sensitive that you alert on brief spikes.
L2 → "Who decides what the SLO threshold should be? Engineering or product?"
Joint decision, but different inputs: Engineering provides the measured current reliability (what's achievable given the architecture). Product provides the user tolerance (what level of reliability users need for the business case). Operations provides the cost of achieving higher reliability. The SLO sits where these three overlap. Engineers should push back on aspirational SLOs that require infrastructure investment not yet funded.
L3 → "Multi-window burn rate alerts — why do you need both a short window and a long window?"
Short window (5m) alone: catches fast burns quickly but creates noise on brief spikes. Long window (1h) alone: catches sustained burns but misses sudden outages (a 100% error rate for 10 minutes might not fire a 1h window alert). The AND condition: both must exceed threshold simultaneously. This means: sustained high burn rate (1h window catches it) AND currently still burning fast (5m window confirms it's ongoing, not a resolved spike). Reduces alert noise by ~95% compared to single-window alerts according to the SRE Workbook.
Trap answer to avoid: "SLO is just a fancier word for uptime percentage." — Uptime (server is responding) and SLO (users are getting the experience they need) are different. A server can be up but returning 500s — uptime is 100%, SLO is breached. SLOs should be user-experience metrics (latency, error rate on successful requests), not infrastructure metrics (CPU, memory, server restarts).
Q6 · Debugging
Walk me through debugging a sudden latency spike on a production endpoint.

Direct answer

Latency debugging is structured: metrics establish the scope, traces locate the bottleneck, and logs explain the root cause. Skipping ahead to logs without traces is the most common mistake — you end up grepping millions of lines without knowing which request to look at.

The step-by-step approach

Step 1 — Scope the problem. Is it all endpoints or one specific endpoint? All users or a specific region/tenant? Did it start with a deployment? Check the RED dashboard: rate, error rate, P99 latency. Check if it's correlated with a deploy timestamp.

Step 2 — Find the slow traces. Open your tracing tool (Jaeger/Tempo), filter by the affected endpoint and time window, sort by duration descending. Find traces at the P99 boundary. Look at the flame chart — which span is taking the most time?

Step 3 — Check the DB first (it's the most common cause).

-- Active queries and their duration (PostgreSQL)
SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle' AND query_start IS NOT NULL
ORDER BY duration DESC;

-- Slowest queries by total time
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 20;

Also check HikariCP pool: /actuator/metrics/hikaricp.connections.active — if it's at max, requests are queuing for a connection.

Step 4 — Check GC. /actuator/metrics/jvm.gc.pause — long GC pauses spike P99. Also check heap usage: jvm.memory.used. GC pauses of 500ms+ will appear as consistent P99 spikes across all endpoints simultaneously.

Step 5 — Check thread pool. /actuator/metrics/executor.active — if at max, requests are queueing. /actuator/threaddump to see what threads are blocked on (a lock, I/O, a downstream call).

Step 6 — Check downstream services. Did the slow span involve a downstream HTTP call? Check the downstream service's metrics. Check circuit breaker state. Check connection pool to downstream.

The production gotcha

The latency spike that resolves itself — GC pauses look exactly like this. A full GC pause of 2 seconds every few minutes causes P99 spikes that look intermittent and mysterious. Find it in GC logs or jvm.gc.pause metrics. Root cause is usually heap pressure: memory leak, or a sudden burst of object allocation (processing a large payload). Fix: increase heap, tune GC settings, or find and fix the allocation source using async-profiler in allocation profiling mode.

Interview drill-down — expect these follow-ups:
L1 → "You checked pg_stat_statements and found a slow query. What are the next three diagnostic steps?"
(1) EXPLAIN ANALYZE the slow query — is it doing a sequential scan when an index should exist? Is the planner choosing the wrong join strategy? (2) Check pg_stat_user_tables for seq_scan count on the affected table — if it's high and the table is large, you're missing an index. (3) Check if the query recently became slow (statistics may be stale) — ANALYZE the table to refresh statistics, or check if a recent data pattern change caused the planner to pick a different execution plan.
L2 → "HikariCP pool exhausted — all 10 connections active. What are the root causes?"
Most common causes: (1) Long-running transactions not being committed promptly — connection held for entire transaction duration including non-DB work. (2) DB is slow — queries taking longer than usual means connections are held longer, pool runs out sooner. (3) Thread blocked while holding connection — doing HTTP calls inside a @Transactional method. (4) Connection leak — code path that doesn't close the connection on exception. Check hikaricp.connections.active, hikaricp.connections.pending, and enable leak-detection-threshold in HikariCP config.
L3 → "Latency spike affecting only specific users/tenants, not all traffic. Where do you look?"
This is a data-skew or noisy-neighbor problem. Steps: (1) Filter traces by the affected users — look for a common attribute (tenant ID, region, account type). (2) Check if affected users trigger a specific code path (premium features, large data exports). (3) Check DB query plans for those users' data — if their data is very large, queries may take much longer. (4) Check if those users are sharing infrastructure resources (same DB shard, same cache cluster) with a particularly heavy actor. Per-tenant metrics (rate, latency tagged by tenantId) would have caught this faster.
Trap answer to avoid: "I'd enable DEBUG logging to investigate." — DEBUG logging in production at high throughput can itself cause the latency spike (log I/O is synchronous by default). The right first tool is traces — they're already sampled and indexed. Enable DEBUG logging for specific packages only via /actuator/loggers if absolutely needed, and disable immediately after.
Q7 · Health Probes
What's the difference between liveness and readiness probes, and how do you implement them correctly?

Direct answer

Liveness and readiness answer different questions. Liveness: "Is this pod alive — should Kubernetes restart it?" Readiness: "Is this pod ready to serve traffic — should the load balancer send requests to it?" The critical distinction: liveness failure triggers a restart; readiness failure only removes the pod from load balancing rotation. Confusing them is a common source of restart loops in production.

What each probe should check

Liveness probe — checks only that the JVM is alive and responsive. Never check downstream dependencies (DB, Redis, Kafka) in a liveness probe. If Redis goes down and your liveness probe fails, Kubernetes restarts your pod. The restart doesn't fix Redis, and now you've lost warm caches and in-flight requests. Restart loop begins.

Readiness probe — can check critical downstream dependencies. If the DB connection pool is exhausted or unavailable, the pod shouldn't receive traffic — it can't serve requests. But only check dependencies that are truly required to serve any request. Optional dependencies should not make the pod unready.

# application.yml
management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        liveness:
          include: livenessState     # only JVM liveness — never downstream
        readiness:
          include: readinessState, db, redis   # check critical deps
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

Custom readiness health indicator for Kafka:

@Component
class KafkaReadinessIndicator implements HealthIndicator {

    @Override
    public Health health() {
        try {
            kafkaAdmin.describeTopics(List.of("orders"));
            return Health.up().build();
        } catch (Exception e) {
            return Health.down().withException(e).build();
        }
    }
}

Startup probe — for slow-starting apps

For services that take 30–60s to start (large Spring context, DB migration on startup), liveness probes can kill the pod before it's ready. Startup probes solve this: Kubernetes uses only the startup probe until it passes, then switches to liveness/readiness. This prevents premature restarts of slow-starting services:

# Kubernetes spec
startupProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  failureThreshold: 30      # 30 × 10s = 5 minutes to start
  periodSeconds: 10
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  periodSeconds: 10

The production gotcha

Slow liveness probe causing a restart loop: the probe hits /actuator/health, which checks the DB, which is slow under load, which causes the probe to time out, which triggers a restart, which makes the DB load worse. The fix: liveness probe must be fast and dependency-free. If it takes more than 100ms to respond, something is wrong with the probe design, not the service.

Interview drill-down — expect these follow-ups:
L1 → "Liveness probe checking the DB — exactly what goes wrong in the failure scenario?"
DB goes slow → /actuator/health takes 5s to respond → liveness probe times out (threshold 3s) → Kubernetes kills the pod → pod restarts → establishes new DB connections → DB now has more connections from the restart event → DB gets even slower → more pods fail their liveness probes → mass restart event. The DB slowness caused by a legitimate traffic spike becomes a self-inflicted restart cascade. Liveness probes must be fast and dependency-free to avoid this.
L2 → "What is a graceful shutdown and what happens to in-flight requests if you skip it?"
Without graceful shutdown: SIGTERM arrives → Spring immediately stops the Tomcat thread pool → in-flight requests get a connection reset or 502 from the load balancer. With graceful shutdown (spring.lifecycle.timeout-per-shutdown-phase): SIGTERM → readiness probe returns DOWN → Kubernetes removes pod from load balancer → no new requests arrive → existing requests complete (or timeout after the grace period) → JVM exits cleanly. The preStopHook sleep (typically 5–10s) adds buffer for the load balancer to drain before shutdown begins.
L3 → "Your service has a 60-second startup time (Flyway migrations + large Spring context). How do you configure probes?"
Use startupProbe with failureThreshold × periodSeconds covering your worst-case startup: failureThreshold=18, periodSeconds=10 = 180s startup budget. startupProbe checks /actuator/health/liveness. Once the startup probe succeeds (JVM is responsive), Kubernetes switches to livenessProbe (same endpoint, faster period). The readinessProbe (with DB check) also starts after startup succeeds — pod only receives traffic once DB is confirmed available. Never use a single probe for both startup and runtime health — the thresholds are incompatible.
Trap answer to avoid: "I check all my dependencies in both liveness and readiness." — Checking dependencies in liveness causes restart loops on downstream failures. The correct separation: liveness = "is this JVM alive?" (no dependencies), readiness = "can this pod serve traffic?" (critical dependencies only). Getting this wrong causes cascading restarts in production during dependency outages.
Q8 · OpenTelemetry
How do you instrument a Spring Boot service with OpenTelemetry? What does zero-code instrumentation give you?

Direct answer

OpenTelemetry (OTel) is a CNCF standard for observability — one SDK and API for logs, metrics, and traces, with pluggable exporters to any backend. The Java agent provides zero-code auto-instrumentation: attach it as a -javaagent flag and it automatically instruments Spring MVC, WebFlux, JDBC, Kafka, Redis, scheduling, and more. You get traces for every operation without writing a single line of instrumentation code.

Zero-code instrumentation with the Java agent

# JVM args — attach the agent
JAVA_TOOL_OPTIONS=-javaagent:/path/to/opentelemetry-javaagent.jar

# Environment variables for configuration
OTEL_SERVICE_NAME=payment-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1      # sample 10%
OTEL_LOGS_EXPORTER=otlp          # send structured logs to collector too
OTEL_METRICS_EXPORTER=otlp

What you get automatically with the agent:

  • Spans for every inbound HTTP request (Spring MVC/WebFlux) with HTTP method, URL, status code
  • Spans for every outbound HTTP call (RestTemplate, WebClient, OkHttp) with target URL
  • Spans for every JDBC query with sanitised SQL (bind params removed)
  • Spans for every Kafka produce and consume with topic, partition, consumer group
  • Spans for Redis operations (Lettuce, Jedis)
  • W3C traceparent header injection/extraction on HTTP and Kafka message headers
  • TraceId and spanId injected into MDC for log correlation

Manual instrumentation for custom spans

// Option 1: @WithSpan annotation (OTel annotation library)
@WithSpan("process-payment")
public PaymentResult processPayment(@SpanAttribute("order.id") String orderId) {
    return doProcess(orderId);
}

// Option 2: programmatic span
Tracer tracer = GlobalOpenTelemetry.getTracer("payment-service");
Span span = tracer.spanBuilder("fraud-check").startSpan();
try (Scope scope = span.makeCurrent()) {
    span.setAttribute("order.id", orderId);
    span.setAttribute("order.amount", amount);
    return fraudService.check(orderId);
} catch (Exception e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}

The OTel Collector — the standard production pattern

Services don't export directly to Jaeger/Datadog/Tempo. They send to an OTel Collector, which receives telemetry, processes it (sample, enrich with k8s metadata, filter noisy spans), and exports to one or more backends. This decouples your services from your observability backend — changing from Jaeger to Tempo doesn't require touching application config.

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 1s
  tail_sampling:
    policies:
      - name: errors-policy
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow-traces-policy
        type: latency
        latency: { threshold_ms: 1000 }

exporters:
  jaeger:
    endpoint: jaeger:14250
  prometheus:
    endpoint: "0.0.0.0:8889"

The production gotcha

The OTel Java agent instruments JDBC and logs the SQL statement by default. Verify it's sanitising bind parameters — you don't want WHERE password = 'hunter2' appearing in your traces. It sanitises by default in recent versions, but confirm with your specific agent version. Also: disable the agent in tests with OTEL_SDK_DISABLED=true to avoid spurious spans in test output and potential test interference.

Interview drill-down — expect these follow-ups:
L1 → "OTel agent instruments JDBC automatically. Does it record bind parameter values?"
By default, the OTel Java agent sanitises SQL by removing bind parameter values — you see "WHERE user_id = ?" not "WHERE user_id = 12345". This is the safe default to prevent PII leakage. You can disable sanitisation with OTEL_INSTRUMENTATION_COMMON_DB_STATEMENT_SANITIZER_ENABLED=false for debugging, but never leave this disabled in production. Verify the actual SQL being captured in your development environment before deploying.
L2 → "OTel Collector tail sampling — what happens if the Collector runs out of memory buffering traces?"
The Collector will drop spans. Tail sampling is memory-intensive: it must hold all spans for a trace (across all services) for up to 30s waiting for the trace to complete. Under high load, the Collector's memory grows fast. Mitigations: horizontal scaling (multiple Collector replicas with consistent hashing to route trace IDs to the same Collector replica), aggressive pre-filtering at receivers (drop known noisy spans before tail sampling), and setting an explicit memory_limiter processor that drops when above threshold. Always size the Collector for 2x peak load.
L3 → "What is the difference between OTel Collector in agent mode vs gateway mode?"
Agent mode: a Collector sidecar runs alongside each service pod (DaemonSet or sidecar container). Services export to localhost — very low latency, no network hop. Gateway mode: a central Collector cluster receives from all services over the network. Agent mode is simpler and lower-latency; gateway mode is better for centralized processing (tail sampling requires all spans from a trace to arrive at the same Collector). The standard production pattern: agents collect and forward to a central gateway that does the heavy processing and exports to backends.
Trap answer to avoid: "I use OTel and Micrometer independently — they're separate tools." — In Spring Boot 3, they integrate: Micrometer Tracing can use OTel as its backend via micrometer-tracing-bridge-otel. The OTel Java agent can auto-instrument and then bridge to Micrometer. Running both independently creates duplicate spans and conflicting trace contexts. Pick one integration path: either OTel agent (zero-code, full OTel) or Micrometer Tracing (more Spring-native, code-based). Don't mix without understanding the integration layer.