<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ramesh Yara</title>
    <description>The latest articles on DEV Community by Ramesh Yara (@ramesh-yara).</description>
    <link>https://dev.to/ramesh-yara</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070124%2F1e527bdf-d0ce-43f6-a7b4-2d49f8e297d2.jpg</url>
      <title>DEV Community: Ramesh Yara</title>
      <link>https://dev.to/ramesh-yara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ramesh-yara"/>
    <language>en</language>
    <item>
      <title>Who Did This? Identity Across Async Boundaries</title>
      <dc:creator>Ramesh Yara</dc:creator>
      <pubDate>Sun, 09 Aug 2026 18:20:05 +0000</pubDate>
      <link>https://dev.to/ramesh-yara/who-did-this-identity-across-async-boundaries-1n4n</link>
      <guid>https://dev.to/ramesh-yara/who-did-this-identity-across-async-boundaries-1n4n</guid>
      <description>&lt;p&gt;You put a lot of work into authentication. A gateway validates the Keycloak JWT, maps realm roles to authorities, checks that the caller is allowed. By the time a request reaches your service, you know exactly who is calling.&lt;/p&gt;

&lt;p&gt;Then the request crosses into async land, and all of that evaporates.&lt;/p&gt;

&lt;p&gt;This is the story of the point where identity quietly disappears in an event-driven system, why the dead-letter queue is the worst possible place for it to disappear, and how I made the acting user as durable and replay-safe as the event itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow everyone believes is fine
&lt;/h3&gt;

&lt;p&gt;The platform is a set of Spring Boot services: an API gateway in front, a &lt;code&gt;user-service&lt;/code&gt; on MySQL, a &lt;code&gt;notification-service&lt;/code&gt; on PostgreSQL, and Kafka carrying events between them. A user is created, an event is published, a notification is sent.&lt;/p&gt;

&lt;p&gt;Authentication is handled at the edge. The gateway is an OAuth2 Resource Server; it validates the token once and propagates the caller's identity downstream as headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api-gateway — IdentityPropagationFilter (@Order(2), after security)&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;identityContextExtractor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Always set all three headers (empty when absent) to mask any spoofed values.&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;nullToEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullToEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;enrichedRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IdentityHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_ROLES&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DELIM&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail here matters more than it looks. The headers are &lt;strong&gt;always overwritten&lt;/strong&gt;, even when a claim is absent. If a client tries to inject &lt;code&gt;X-User-Name: admin&lt;/code&gt; on the inbound request, the gateway stomps it with the validated value (or empty). Downstream trust in those headers is only safe because the perimeter guarantees they cannot be forged. Miss that, and you've built an impersonation API.&lt;/p&gt;

&lt;p&gt;So far, so good. The synchronous hop carries identity. The problem starts one line later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden failure: the thread boundary
&lt;/h3&gt;

&lt;p&gt;I don't publish to Kafka inside the request. I use the transactional outbox pattern: the request persists the user &lt;strong&gt;and&lt;/strong&gt; an outbox row in one local transaction, and a separate scheduled poller publishes to Kafka afterward. (Why: a direct publish inside the request can commit the DB row and then lose the event if the broker call fails — the dual-write problem. The outbox closes that gap.)&lt;/p&gt;

&lt;p&gt;That decoupling is correct for delivery. It is also exactly where identity dies.&lt;/p&gt;

&lt;p&gt;The outbox publisher runs on a &lt;strong&gt;scheduled thread&lt;/strong&gt;, not the request thread. &lt;code&gt;SecurityContextHolder&lt;/code&gt;, &lt;code&gt;ThreadLocal&lt;/code&gt;, &lt;code&gt;MDC&lt;/code&gt; — every ambient place you might have stashed "who is calling" — are all empty by the time the poller runs. There is no request. There is no token. There is nothing to read.&lt;/p&gt;

&lt;p&gt;So the event goes out anonymous. The consumer logs anonymous. The notification is written anonymous. And when an event exhausts its retries and lands in the dead-letter queue — the one moment you will &lt;em&gt;urgently&lt;/em&gt; want to know who triggered it — the dead-letter row is anonymous too. Provenance vanishes at precisely the point forensics begins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The naive fixes (and why each one fails)
&lt;/h3&gt;

&lt;p&gt;The tempting answers all fail at the same boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Just log the username in the controller."&lt;/strong&gt; You can — but the log line you care about is the &lt;em&gt;publish&lt;/em&gt;, which happens later, on another thread, after the HTTP response has already returned. The controller log tells you a request arrived; it can't attribute the event that failed twenty seconds later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Stash it in MDC / a ThreadLocal and read it in the publisher."&lt;/strong&gt; The publisher isn't on your thread. MDC is thread-scoped; the scheduled poller starts with a clean, empty context. You'll read &lt;code&gt;null&lt;/code&gt; every time. Worse, under Virtual Threads with pooled carriers, a &lt;em&gt;stale&lt;/em&gt; ThreadLocal is a correctness hazard — you can leak one request's identity onto another's event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Re-read the JWT in the publisher."&lt;/strong&gt; There is no request in scope and no token to re-validate. The authentication event is long over.&lt;/p&gt;

&lt;p&gt;Every naive fix assumes identity lives in ambient thread state. Across an async boundary, ambient state is exactly what you don't have.&lt;/p&gt;

&lt;h3&gt;
  
  
  The production implementation: persist identity with the event
&lt;/h3&gt;

&lt;p&gt;If the publish is decoupled from the request in &lt;em&gt;time&lt;/em&gt; and &lt;em&gt;thread&lt;/em&gt;, then identity has to travel the same way the event does — &lt;strong&gt;as data, not as ambient context&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I capture the actor on the request thread and persist it onto the outbox row, inside the same transaction as the entity write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user-service — OutboxEventService (runs on the request thread)&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdentityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;MDC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CorrelationConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;OutboxEvent&lt;/span&gt; &lt;span class="n"&gt;outboxEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OutboxEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;(...).&lt;/span&gt;&lt;span class="na"&gt;aggregateType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"USER_CREATED"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxEventStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;   &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;         &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;   &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;outboxEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outboxEvent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same TX as the user write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now identity is as durable as the event. It survives a crash, a restart, a redeploy — because it's a committed row, not a value on a dying thread.&lt;/p&gt;

&lt;p&gt;The scheduled publisher later rehydrates that context from the row and rides it onto the message as Kafka headers — supplied &lt;strong&gt;explicitly&lt;/strong&gt;, never read from MDC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user-service — EventPublisher (runs on the scheduled outbox thread)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publishUserCreatedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserCreatedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="no"&gt;TOPIC&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_TRACE_ID_HEADER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;addHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;KAFKA_USER_ROLES&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer does the mirror operation: read the headers, rebind identity to the consumer thread and MDC so every log line is attributed — and, critically, &lt;strong&gt;clear it in a &lt;code&gt;finally&lt;/code&gt; block&lt;/strong&gt; so nothing bleeds into the next message on a reused thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// notification-service — KafkaConsumerService&lt;/span&gt;
&lt;span class="nc"&gt;IdentityContext&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restoreIdentityContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userNameHeader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userEmailHeader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userRolesHeader&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendWelcomeNotification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;clearIdentityContext&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// MDC.remove(...) + IdentityContextHolder.clear()&lt;/span&gt;
    &lt;span class="no"&gt;MDC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CorrelationConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRACE_ID&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single immutable &lt;code&gt;IdentityContext&lt;/code&gt; record is the canonical "who is acting" shape at every hop — serialized to HTTP headers at the gateway, to a DB row in the outbox, to Kafka headers on publish, back to MDC on consume. It's deliberately transport-agnostic, so a sync→async hop never has to re-derive trust from the token.&lt;/p&gt;

&lt;p&gt;Here's the whole path, including the failure branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[HTTP request thread]                    [scheduled thread]           [consumer thread]
Gateway (validate JWT)                    Outbox Publisher             @KafkaListener
   │ X-User-* headers (overwritten)          │ read actor from row        │ read actor from headers
   ▼                                          ▼                            ▼ bind to MDC
user-service ──save user + outbox row──▶ outbox_events ──Kafka headers──▶ process + log (attributed)
             (one transaction: entity                                     │
              + actor_username/email/roles)                               │ retries exhausted
                                                                          ▼
                                                             @DltHandler → dead_letter_events
                                                             (actor_* columns persisted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The payoff: the dead-letter queue keeps its memory
&lt;/h3&gt;

&lt;p&gt;The reason all of this is worth it lives in the &lt;code&gt;@DltHandler&lt;/code&gt;. When every retry is exhausted, the failed event is persisted for triage &lt;strong&gt;with the originating actor attached&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;deadLetterEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeadLetterEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEventId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actorRoles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rolesAsString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dead letter without an actor is a mystery ticket. A dead letter &lt;em&gt;with&lt;/em&gt; one is an incident you can route, reproduce, and explain. The schema changes that back this are explicit migrations — Liquibase &lt;code&gt;actor_*&lt;/code&gt; + &lt;code&gt;trace_id&lt;/code&gt; on &lt;code&gt;outbox_events&lt;/code&gt; (MySQL), Flyway &lt;code&gt;actor_*&lt;/code&gt; on &lt;code&gt;dead_letter_events&lt;/code&gt; and &lt;code&gt;notification_log&lt;/code&gt; (PostgreSQL) — not &lt;code&gt;ddl-auto&lt;/code&gt; guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification: prove it survives the failure path
&lt;/h3&gt;

&lt;p&gt;Assertion isn't evidence. I verified the branch that matters — the failure one — by fault injection: force the notification step to fail so an event exhausts its four attempts and dead-letters, then read the audit table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;dead_letter_events&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;failed_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- actor_username / actor_email populated on the failed row ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identity is there, on a &lt;em&gt;failed&lt;/em&gt; event, written by a background thread that never saw the original request. That's the whole thesis in one row.&lt;/p&gt;

&lt;p&gt;A subtle bonus: because identity rides Kafka headers as data, it survives even where distributed &lt;em&gt;tracing&lt;/em&gt; currently doesn't. On this platform the OTel agent doesn't yet emit Kafka spans (a gap I wrote about separately), so the async trace edge is blind — yet the actor still crosses it, because header-borne data doesn't depend on span instrumentation. Two independent propagation mechanisms; the durable one keeps working when the automatic one has a hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication is not propagation.&lt;/strong&gt; Validating a token proves a request is allowed &lt;em&gt;now&lt;/em&gt;; it says nothing about who's behind the event that fails an hour later on another thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Across an async boundary, identity must be data, not ambient context.&lt;/strong&gt; ThreadLocal/MDC/SecurityContext are all empty on the publisher and consumer threads. Persist the actor with the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach identity in the same transaction as the write.&lt;/strong&gt; That's what makes it as durable and replay-safe as the event — surviving crashes, retries, and redeploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dead-letter queue is where provenance matters most and is usually lost.&lt;/strong&gt; Persisting &lt;code&gt;actor_*&lt;/code&gt; on the DLT row turns an anonymous mystery into a routable incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust in propagated headers requires an unspoofable perimeter.&lt;/strong&gt; Unconditionally overwrite &lt;code&gt;X-User-*&lt;/code&gt; at the gateway, or you've built impersonation-as-a-service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear context in &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; Under Virtual Threads and pooled consumers, a stale ThreadLocal is a cross-request correctness bug, not just untidy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform is open source — gateway, both services, the outbox, and this identity path end to end: &lt;a href="https://github.com/Rummy43/ai-microservices-platform" rel="noopener noreferrer"&gt;https://github.com/Rummy43/ai-microservices-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When one of your events lands in the dead-letter queue tonight, can you tell who triggered it?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@yara.ramesh/who-did-this-identity-across-async-boundaries-823c712b073f" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>springboot</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your Service Map Is Lying</title>
      <dc:creator>Ramesh Yara</dc:creator>
      <pubDate>Sun, 09 Aug 2026 18:10:22 +0000</pubDate>
      <link>https://dev.to/ramesh-yara/your-service-map-is-lying-4g47</link>
      <guid>https://dev.to/ramesh-yara/your-service-map-is-lying-4g47</guid>
      <description>&lt;p&gt;You attach the OpenTelemetry Java agent, point it at a collector, and within minutes Grafana is drawing a service map you never drew. A box for each service, arrows between them, latency on every edge. It feels like magic, and — more dangerously — it feels &lt;em&gt;complete&lt;/em&gt;. "The agent traces everything" is the sentence repeated in every onboarding doc.&lt;/p&gt;

&lt;p&gt;This is the story of the moment that sentence stopped being true on my platform, why I'm glad it did, and the difference between a system that is &lt;em&gt;working&lt;/em&gt; and a system you can actually &lt;em&gt;see&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow everyone trusts
&lt;/h3&gt;

&lt;p&gt;The platform is an event-driven set of Spring Boot services: an API gateway in front, a &lt;code&gt;user-service&lt;/code&gt; backed by MySQL, a &lt;code&gt;notification-service&lt;/code&gt; backed by PostgreSQL, and Kafka carrying events between them. A user is created, an event is published, a notification is sent.&lt;/p&gt;

&lt;p&gt;I didn't want to &lt;em&gt;draw&lt;/em&gt; that topology. A hand-drawn architecture diagram is documentation that drifts — true the day you commit it, slightly wrong a month later, actively misleading after a quarter. I wanted the dependency graph &lt;strong&gt;generated from live traffic&lt;/strong&gt;, so it would always reflect what the system actually does.&lt;/p&gt;

&lt;p&gt;Grafana Tempo does exactly this. Its &lt;code&gt;service-graphs&lt;/code&gt; processor reads matched client/server span pairs out of trace data and emits a metric — &lt;code&gt;traces_service_graph_request_total&lt;/code&gt; — that Grafana renders as a node graph. No edge is ever wired by hand. The topology is derived, continuously, from real spans.&lt;/p&gt;

&lt;h3&gt;
  
  
  The edge that wasn't there
&lt;/h3&gt;

&lt;p&gt;I generated the graph and the synchronous edges lit up immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;api-gateway → user-service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user-service → MySQL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;notification-service → PostgreSQL&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I looked for the one edge I actually cared about — &lt;code&gt;user-service → notification-service&lt;/code&gt;, the asynchronous hop over Kafka.&lt;/p&gt;

&lt;p&gt;It wasn't there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The naive conclusion (and why it's wrong)
&lt;/h3&gt;

&lt;p&gt;The tempting read is immediate and obvious: &lt;em&gt;the async hop is broken. The event isn't getting across. Go debug the consumer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I checked. And the consumer was completely fine. &lt;code&gt;notification-service&lt;/code&gt; had consumed every event and written every corresponding row to PostgreSQL. Its database edge was lit. Liveness was perfect; the feature worked end to end.&lt;/p&gt;

&lt;p&gt;That is the trap. &lt;strong&gt;A missing edge looks exactly like a broken feature&lt;/strong&gt;, and the instinct is to go "fix" something that was never broken. The defect wasn't in the message path at all — it was in the &lt;em&gt;observability&lt;/em&gt; of the message path. Those are two different failure domains that happen to render identically on a dashboard.&lt;/p&gt;

&lt;p&gt;So I stopped trusting the picture and went to the source of truth: the trace store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evidence over assertion
&lt;/h3&gt;

&lt;p&gt;Two TraceQL queries settled it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ span.messaging.system = "kafka" }
→ 0 results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ resource.service.name = "user-service" } &amp;amp;&amp;amp; { resource.service.name = "notification-service" }
→ 0 traces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero Kafka messaging spans anywhere in the system. Zero traces spanning both services. The agent — this build, under this Spring Boot version — simply was not instrumenting the Kafka client. Nothing errored. No warning was logged. The map wasn't wrong about the data; &lt;strong&gt;the data was never produced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the part worth sitting with: a green dashboard would have let me believe the chain was fully traced. The absence of red is not the presence of coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the graph is actually built
&lt;/h3&gt;

&lt;p&gt;The pipeline that produces the map is worth seeing, because one of its links is &lt;em&gt;also&lt;/em&gt; a silent path you have to consciously turn on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OTel Java Agent (spans)
        │
        ▼
Tempo  (service-graphs processor)
        │  remote_write
        ▼
Prometheus  (--web.enable-remote-write-receiver)
        │  query
        ▼
Grafana  (Tempo data source → serviceMap → nodeGraph)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate decisions shaped it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated edges, not declared ones.&lt;/strong&gt; Tempo reads client/server span pairs and emits the edge metric. The graph is a test, not a drawing — it &lt;em&gt;fails&lt;/em&gt; when reality diverges from expectation, which is exactly what makes it valuable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One processor, scoped on purpose.&lt;/strong&gt; I enabled &lt;code&gt;service-graphs&lt;/code&gt; only — deliberately not &lt;code&gt;span-metrics&lt;/code&gt;. The latter generates RED/latency series and would have inflated cardinality for a deliverable that was strictly about topology. Minimal blast radius, single responsibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A push boundary you must open explicitly.&lt;/strong&gt; Tempo &lt;em&gt;remote-writes&lt;/em&gt; its generated metrics into Prometheus — the inverse of every other component, which Prometheus scrapes. That requires flipping Prometheus into a receiver with &lt;code&gt;--web.enable-remote-write-receiver&lt;/code&gt;. Forget it, and the metrics silently never land. Same lesson as the Kafka gap, one layer down: the data paths that fail quietly are the ones nobody turned on.&lt;/p&gt;

&lt;h3&gt;
  
  
  One missing edge, two real causes
&lt;/h3&gt;

&lt;p&gt;There's a subtlety the graph forced me to articulate. Even once the agent emits Kafka spans, the &lt;code&gt;user-service → notification-service&lt;/code&gt; edge would &lt;em&gt;still&lt;/em&gt; not look like a normal synchronous call — because my publish doesn't happen on the request thread.&lt;/p&gt;

&lt;p&gt;I use the transactional outbox pattern: the request persists the user and an outbox row in one local transaction, and a scheduled poller publishes to Kafka afterward. So the Kafka branch roots its &lt;em&gt;own&lt;/em&gt; trace, off the poll cycle, structurally separate from the originating HTTP request. That's the outbox decoupling working as designed — not a propagation bug.&lt;/p&gt;

&lt;p&gt;So a single missing edge had two distinct, both-legitimate explanations: a real &lt;strong&gt;instrumentation gap&lt;/strong&gt; (no Kafka spans) and a real &lt;strong&gt;design boundary&lt;/strong&gt; (async decoupling). Conflating them would have been the naive read. The generated graph didn't just find a bug — it made an architecture decision legible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the map legible, not just generated
&lt;/h3&gt;

&lt;p&gt;There was one more way the graph was technically correct but practically dishonest: both databases first rendered as a single node named &lt;code&gt;localhost&lt;/code&gt;. The agent labels an uninstrumented peer by its host, and both databases live on the loopback interface — so MySQL and PostgreSQL collapsed into one vertex. A heterogeneous-persistence design rendered as if it shared one database.&lt;/p&gt;

&lt;p&gt;The fix is a peer-service mapping on the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;-Dotel.instrumentation.common.peer-service-mapping&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;localhost:3306=MySQL,localhost:5432=PostgreSQL,&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;localhost:8085=SchemaRegistry,localhost:8180=Keycloak&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each dependency resolves to a real, named vertex, and the Schema Registry shows up as its own node. I confirmed it the same way I confirmed everything else — in the backend, not the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"MySQL"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt;
&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"PostgreSQL"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt;
&lt;span class="n"&gt;traces_service_graph_request_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"localhost"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A graph that says &lt;code&gt;localhost&lt;/code&gt; is &lt;em&gt;generated&lt;/em&gt;; a graph that says &lt;code&gt;MySQL&lt;/code&gt; and &lt;code&gt;PostgreSQL&lt;/code&gt; is &lt;em&gt;legible&lt;/em&gt;. The labels are what turn telemetry into topology.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-instrumentation is a claim, not a guarantee.&lt;/strong&gt; "The agent traces everything" holds until a version boundary quietly says otherwise. Verify spans exist in the backend; never infer coverage from the absence of errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missing edge is not a missing feature.&lt;/strong&gt; Liveness and observability are independent failure domains. A system can be correct and unobservable at the same time — the most dangerous state, because it looks fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generated graph is a test, not a decoration.&lt;/strong&gt; Its entire value is that it can fail when reality diverges. A hand-drawn diagram cannot fail; that's precisely why it's worthless as a safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent data paths must be consciously enabled.&lt;/strong&gt; Remote-write receivers, peer mappings, opt-in instrumentation — the telemetry that "just never lands" is the telemetry nobody turned on. Make the gaps loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name your peers.&lt;/strong&gt; Topology you can't read is only half-built; labels are the difference between a generated graph and a useful one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with queries, not screenshots.&lt;/strong&gt; Green is the absence of evidence, not evidence of absence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform is open source — gateway, two services, Kafka, the full observability stack, and this service-graph setup: &lt;a href="https://github.com/Rummy43/ai-microservices-platform" rel="noopener noreferrer"&gt;https://github.com/Rummy43/ai-microservices-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's the most expensive silent gap you've found by verifying instead of trusting the dashboard?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@yara.ramesh/your-service-map-is-lying-cfc84fb38990" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>observability</category>
      <category>kafka</category>
      <category>distributedsystems</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
