<?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: Paolo</title>
    <description>The latest articles on DEV Community by Paolo (@purbano).</description>
    <link>https://dev.to/purbano</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%2F4046772%2F0302ec31-c0a4-4e02-a0fb-dc87bbea8533.png</url>
      <title>DEV Community: Paolo</title>
      <link>https://dev.to/purbano</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/purbano"/>
    <language>en</language>
    <item>
      <title>The Row Says 'system': Spring Data JPA Auditing Outside the HTTP Request</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:49:03 +0000</pubDate>
      <link>https://dev.to/purbano/the-row-says-system-spring-data-jpa-auditing-outside-the-http-request-f77</link>
      <guid>https://dev.to/purbano/the-row-says-system-spring-data-jpa-auditing-outside-the-http-request-f77</guid>
      <description>&lt;p&gt;Every table in our platform has the same four columns at the end: &lt;code&gt;created_by&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_by&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;. Spring Data JPA fills them in for you with four annotations and one &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;. That part takes five minutes.&lt;/p&gt;

&lt;p&gt;Then you look at the data a month later and find rows that say &lt;code&gt;system&lt;/code&gt;, and you cannot tell which ones deserved it. Some of them really were written by a scheduled job. Some were written by a queue consumer on behalf of a real person whose identity got lost on the way. A few were written by a user whose session had not been set up yet. All three look identical in the audit trail, which makes the audit trail useless for exactly the question it exists to answer.&lt;/p&gt;

&lt;p&gt;What follows is what happens after those first five minutes: where the auditor's identity comes from, what to return when there is no logged-in user, what to actually put in that column, and how to package all of it in a library that a dozen services can share without any of them breaking at startup. The setup is deliberately not Spring Security. Our services keep the logged-in user in a session-scoped bean of their own, and the auditor reads from there. If you use &lt;code&gt;SecurityContextHolder&lt;/code&gt;, the interesting parts still apply, because the problem is not where the identity is stored. The problem is that on a scheduler thread there is no identity to read.&lt;/p&gt;

&lt;p&gt;Everything is in a companion repository: &lt;a href="https://github.com/PaoloUrbano/auditor-aware-demo" rel="noopener noreferrer"&gt;&lt;strong&gt;auditor-aware-demo&lt;/strong&gt;&lt;/a&gt;. It is a single Spring Boot module on an in-memory H2 database, with two test classes that cover the two situations described below, so &lt;code&gt;mvn test&lt;/code&gt; is all you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt; Spring Boot 3.5.16, Spring Data JPA 3.5.13, Hibernate 6.6.53.Final, H2 2.3.232, Java 21. Boot 3.5.x is out of OSS support since July 2026; everything here runs unchanged on 4.x.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Here is the shape of the system: several Spring Boot services, each with its own database, all of them persisting through JPA. A user logs in once, and from then on every row that user writes should carry their id.&lt;/p&gt;

&lt;p&gt;Some rows, though, are not written by a user at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a nightly job that archives stale records;&lt;/li&gt;
&lt;li&gt;a consumer that picks a command off a queue and executes it;&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;@Async&lt;/code&gt; method that was started inside a request and finishes after the request is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spring Data has one hook for all of this, &lt;code&gt;AuditorAware&amp;lt;T&amp;gt;&lt;/code&gt;, and it has one method: &lt;code&gt;Optional&amp;lt;T&amp;gt; getCurrentAuditor()&lt;/code&gt;. Most of this article is about what that method should return, and what it should never do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic setup
&lt;/h2&gt;

&lt;p&gt;The entity side first, because it does not change.&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="nd"&gt;@MappedSuperclass&lt;/span&gt;
&lt;span class="nd"&gt;@EntityListeners&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditingEntityListener&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditableEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@CreatedBy&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"created_by"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;createdBy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@CreatedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"created_at"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@LastModifiedBy&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"updated_by"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;updatedBy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@LastModifiedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"updated_at"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// getters only&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things about this class are deliberate choices rather than the defaults you would end up with by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No setters.&lt;/strong&gt; These fields are written by &lt;code&gt;AuditingEntityListener&lt;/code&gt; and by nobody else. If application code can call &lt;code&gt;setCreatedBy&lt;/code&gt;, sooner or later it will, usually in a "migration" or an "import" that somebody wrote in a hurry. The &lt;code&gt;updatable = false&lt;/code&gt; on the two &lt;code&gt;created_*&lt;/code&gt; columns is the same idea one layer down: even if an entity is loaded, modified and merged back, those two columns are not part of the &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;equals&lt;/code&gt;/&lt;code&gt;hashCode&lt;/code&gt;.&lt;/strong&gt; Do not generate them over these fields. All four change the moment the entity is persisted, so an entity in a &lt;code&gt;HashSet&lt;/code&gt; before &lt;code&gt;save()&lt;/code&gt; is a different object after it. With Lombok that means &lt;code&gt;@Getter&lt;/code&gt; on this class, not &lt;code&gt;@Data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Instant&lt;/code&gt;, not &lt;code&gt;LocalDateTime&lt;/code&gt;.&lt;/strong&gt; The audit trail is going to be read across services that may not share a timezone. &lt;code&gt;Instant&lt;/code&gt; is unambiguous, and Spring Data converts it without configuration.&lt;/p&gt;

&lt;p&gt;Every entity in every service extends this:&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="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"note"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AuditableEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt; &lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IDENTITY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;archived&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then turn auditing on:&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="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableJpaAuditing&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JpaAuditingConfig&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;That is the entire configuration; the auditor is a &lt;code&gt;@Component&lt;/code&gt; implementing &lt;code&gt;AuditorAware&amp;lt;String&amp;gt;&lt;/code&gt;, shown in the next section. Many examples add &lt;code&gt;auditorAwareRef = "auditorAware"&lt;/code&gt; and an explicit &lt;code&gt;@Bean&lt;/code&gt;. The attribute is optional: without it, Spring Data autowires the &lt;code&gt;AuditingHandler&lt;/code&gt; by type, and one &lt;code&gt;AuditorAware&lt;/code&gt; bean in the context is enough. Why it has to be exactly one comes up when the configuration moves into a library.&lt;/p&gt;

&lt;p&gt;One behaviour to know before the first test: on insert, Spring Data fills in &lt;strong&gt;all four&lt;/strong&gt; columns, not just the &lt;code&gt;created_*&lt;/code&gt; pair. &lt;code&gt;@LastModifiedBy&lt;/code&gt; and &lt;code&gt;@LastModifiedDate&lt;/code&gt; are set on creation too, so a freshly inserted row has &lt;code&gt;updated_by == created_by&lt;/code&gt; and &lt;code&gt;updated_at == created_at&lt;/code&gt;. If your reporting logic treats a null &lt;code&gt;updated_at&lt;/code&gt; as "never modified", it is wrong for every row you insert, not just the ones that were later modified. &lt;code&gt;@EnableJpaAuditing(modifyOnCreate = false)&lt;/code&gt; turns this off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the identity comes from
&lt;/h2&gt;

&lt;p&gt;Our services keep the logged-in user in a session-scoped bean:&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="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@SessionScope&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CurrentUser&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Serializable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// getter, setter&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One instance per HTTP session, populated at login. How that bean gets populated, and why it is session-scoped at all, is a topic for another article. What matters here is one property of it: the auditor is a singleton, and what it receives is a proxy. Every call on that proxy is routed to the session of the &lt;em&gt;current request thread&lt;/em&gt;. On a thread that is not serving a request, there is no session to route to, and the call throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;org.springframework.beans.factory.support.ScopeNotActiveException:
  Error creating bean with name 'scopedTarget.currentUser':
  Scope 'session' is not active for the current thread; ...
Caused by: java.lang.IllegalStateException: No thread-bound request found:
  Are you referring to request attributes outside of an actual web request,
  or processing a request outside of the originally receiving thread? ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the auditor has to know, before touching the session, whether there is a request at all. &lt;code&gt;RequestContextHolder&lt;/code&gt; answers that question:&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="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CurrentUserAuditorAware&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AuditorAware&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;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;ANONYMOUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;USER_PREFIX&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="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;CurrentUser&lt;/span&gt; &lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;CurrentUserAuditorAware&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CurrentUser&lt;/span&gt; &lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Optional&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;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getCurrentAuditor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Optional&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;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;explicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&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;explicit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;explicit&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// No request bound to this thread: a scheduled job, a queue consumer, a test.&lt;/span&gt;
        &lt;span class="c1"&gt;// Touching the session-scoped bean here would throw, so we do not.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestAttributes&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Auditors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SYSTEM&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "system", a constant of the shared library&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofNullable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUserId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;USER_PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&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="no"&gt;ANONYMOUS&lt;/span&gt;&lt;span class="o"&gt;));&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 last statement is the happy path: a request, a session, a user in it. The values are namespaced (&lt;code&gt;user:42&lt;/code&gt;, not &lt;code&gt;42&lt;/code&gt;) so that a user, a job and a consumer can never be mistaken for one another; the full argument is in &lt;em&gt;What goes in the column&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;RequestContextHolder&lt;/code&gt; guard is what prevents the exception above. There is a version of this class that checks &lt;code&gt;currentUser == null&lt;/code&gt; instead; it shows up in a lot of codebases and it is dead code. The proxy is injected at startup and is never null; what can be missing on a given thread is the request that the proxy needs in order to reach a session.&lt;/p&gt;

&lt;p&gt;The distinction between &lt;code&gt;anonymous&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt; is deliberate. A request with nobody logged in and no request at all are different situations, and when you are staring at a row three months later, knowing which one produced it is worth a lot more than the two extra constants cost.&lt;/p&gt;

&lt;p&gt;The first three lines, the explicit auditor, are the subject of the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  When there is no user
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;system&lt;/code&gt; is a correct answer to "who wrote this row" when a job did it. It is a poor answer when the row was written by a consumer executing a command that a specific person put on a queue thirty seconds earlier. And it is a wrong answer when an &lt;code&gt;@Async&lt;/code&gt; method, started inside a request by a logged-in user, finishes its work on a pool thread where &lt;code&gt;RequestContextHolder&lt;/code&gt; is empty. All three cases hit the same &lt;code&gt;return Optional.of(Auditors.SYSTEM)&lt;/code&gt;, and the audit trail cannot tell them apart.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating the auditor as something that has to be &lt;em&gt;discovered&lt;/em&gt; from ambient state, and let the code that actually knows who is acting &lt;em&gt;say so&lt;/em&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ThreadLocal&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;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;CURRENT&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;ThreadLocal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;runAs&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;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&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="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="nf"&gt;callAs&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;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Supplier&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&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;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CURRENT&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="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;action&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="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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&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="no"&gt;CURRENT&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="k"&gt;else&lt;/span&gt; &lt;span class="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Optional&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;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofNullable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CURRENT&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="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 thread-local with a try/finally around it, nothing more. The &lt;code&gt;previous&lt;/code&gt; bookkeeping makes it safe to nest, which matters the first time a job calls a service method that has its own &lt;code&gt;runAs&lt;/code&gt;. On Java 25 you would reach for &lt;code&gt;ScopedValue&lt;/code&gt; instead; the shape of the API is the same.&lt;/p&gt;

&lt;p&gt;The auditor checks this first, before looking at any request. An explicit statement of who is acting beats an inference from ambient state.&lt;/p&gt;

&lt;p&gt;Now the three cases become three different rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scheduled job&lt;/strong&gt; names itself:&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="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NightlyArchiveJob&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"job:nightly-archive"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="no"&gt;RETENTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;NightlyArchiveJob&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;noteService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 0 3 * * *"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UTC"&lt;/span&gt;&lt;span class="o"&gt;)&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;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;archiveCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RETENTION&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;archiveCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;AUDITOR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;archiveEachCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;));&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;&lt;code&gt;archiveEachCreatedBefore&lt;/code&gt; does what the name says: it loads the stale notes and flips them one at a time, so each row goes through the auditing listener and gets &lt;code&gt;updated_by = job:nightly-archive&lt;/code&gt;. That is a row you can act on directly, whereas a row that says &lt;code&gt;system&lt;/code&gt; sends you to the logs to find out which process wrote it.&lt;/p&gt;

&lt;p&gt;A side note on the &lt;code&gt;zone&lt;/code&gt; attribute, since it is the kind of thing that only bites in production: a cron expression runs in the JVM's default time zone unless you say otherwise. In a container that is usually UTC; on the laptop where you tested it, it is not. Spelling it out costs nothing and removes a whole category of "why did the job run at 4" questions. The cutoff itself is an &lt;code&gt;Instant&lt;/code&gt;, so the comparison has no time zone to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue consumer&lt;/strong&gt; carries the originating user through. The producer knew who asked for this; it is a header on the message, or a field in the payload. The consumer's only job is not to lose it:&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="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoteCommandConsumer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"consumer:note-commands"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// requestedBy is already namespaced upstream (e.g. "user:42")&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;NoteCommand&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;text&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;requestedBy&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;NoteCommandConsumer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;noteService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&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;auditor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&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;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;()));&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;When the message has no origin, the consumer falls back to its own name (&lt;code&gt;consumer:note-commands&lt;/code&gt;) rather than to &lt;code&gt;system&lt;/code&gt;, so the row still says something specific about where it came from.&lt;/p&gt;

&lt;p&gt;One caveat that has to be stated: &lt;code&gt;requestedBy&lt;/code&gt; is a value the producer controls. This only works if the producer is trusted, which in practice means the queue is internal and the message was authenticated on the way in. An auditor taken from an unauthenticated payload is a spoofable audit trail.&lt;/p&gt;

&lt;p&gt;The value also arrives already namespaced, because normalising an identity into &lt;code&gt;user:42&lt;/code&gt; belongs at the boundary where it enters the system, not in every consumer that happens to handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;@Async&lt;/code&gt; method&lt;/strong&gt; is the same problem in a different costume. Thread-locals do not cross thread boundaries, and that applies equally to &lt;code&gt;RequestContextHolder&lt;/code&gt;, to &lt;code&gt;SecurityContextHolder&lt;/code&gt; and to our own &lt;code&gt;AuditorContext&lt;/code&gt;. Capture the auditor before the hop and re-establish it on the other side, either by hand (&lt;code&gt;String who = ...; executor.submit(() -&amp;gt; AuditorContext.runAs(who, ...))&lt;/code&gt;) or with a &lt;code&gt;TaskDecorator&lt;/code&gt; on the executor that does it for every task. We will not go further into that here. The point is that once the auditor is a value you can hold in a variable, propagating it becomes a solved problem, which it never was while the identity stayed buried in a session proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test that lied to us
&lt;/h3&gt;

&lt;p&gt;The two test classes in the repo are split on purpose: one goes through MockMvc with a real session, the other has no request at all. The second one is where the &lt;code&gt;system&lt;/code&gt; branch gets exercised.&lt;/p&gt;

&lt;p&gt;Our first version of it was a plain &lt;code&gt;@SpringBootTest&lt;/code&gt; calling the service directly. The row came back &lt;code&gt;anonymous&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;system&lt;/code&gt;. &lt;code&gt;anonymous&lt;/code&gt;. Which means the auditor found a request bound to the test thread, found no user on it, and gave the right answer for what it saw. Where did the request come from?&lt;/p&gt;

&lt;p&gt;From Spring itself. When a test's context is a &lt;code&gt;WebApplicationContext&lt;/code&gt; (the default for &lt;code&gt;@SpringBootTest&lt;/code&gt; in a web project), &lt;code&gt;ServletTestExecutionListener&lt;/code&gt; binds a &lt;code&gt;MockHttpServletRequest&lt;/code&gt; to the test thread before every test method. The intent is that code depending on &lt;code&gt;RequestContextHolder&lt;/code&gt; works in tests without MockMvc. The side effect is that a test meant to simulate "no request" is not simulating it at all, and the branch you wrote the test for never runs.&lt;/p&gt;

&lt;p&gt;The fix is one attribute:&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="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WebEnvironment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NONE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditingOutsideRequestTest&lt;/span&gt; &lt;span class="o"&gt;{&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;With no web layer in the context, the listener does not run and nothing gets bound to the thread. That is also what a batch worker's context looks like, and it is the situation this test is meant to cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in the column
&lt;/h2&gt;

&lt;p&gt;The values so far have been strings like &lt;code&gt;user:42&lt;/code&gt; and &lt;code&gt;job:nightly-archive&lt;/code&gt;. That is a decision, and one that is hard to undo once a few million rows carry it.&lt;/p&gt;

&lt;p&gt;The temptation is to store the user's primary key. It is what you have at hand and what the rest of the schema uses, and &lt;code&gt;AuditorAware&amp;lt;Long&amp;gt;&lt;/code&gt; makes it look like the intended choice. Then the first scheduled job runs and you need a value for it. If the column is a &lt;code&gt;BIGINT&lt;/code&gt;, you invent one: &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;-1&lt;/code&gt;, a "system user" row. If the column is a &lt;code&gt;VARCHAR&lt;/code&gt; that happens to always contain digits, you write &lt;code&gt;system&lt;/code&gt; into it, and somewhere downstream a frontend or a report does &lt;code&gt;Long.parseLong(createdBy)&lt;/code&gt; and falls over, though not immediately: it happens weeks later, on the one screen that happens to list rows a job touched.&lt;/p&gt;

&lt;p&gt;The rule we landed on is boring: &lt;strong&gt;one column, one kind of value, and never mix kinds.&lt;/strong&gt; Two ways to honour it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is a user.&lt;/strong&gt; The column is a foreign key to the users table, and every non-human actor is a real row in it: a technical user for each job, and likewise for each consumer and each integration that writes rows. &lt;code&gt;job:nightly-archive&lt;/code&gt; becomes user 10001, with a display name, and the frontend resolves it like any other user without special cases. This is the cleanest option if you already have a users table in every service and you can afford to seed it with the technical accounts. It is also the one where &lt;code&gt;AuditorAware&amp;lt;Long&amp;gt;&lt;/code&gt; is the accurate type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is a string, and the string says what it is.&lt;/strong&gt; The column is text, the values are &lt;code&gt;user:42&lt;/code&gt;, &lt;code&gt;job:nightly-archive&lt;/code&gt;, &lt;code&gt;consumer:note-commands&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;anonymous&lt;/code&gt;, and no code anywhere is allowed to parse a number out of it. The namespace is the guard: nobody looks at &lt;code&gt;user:42&lt;/code&gt; and thinks it is an integer. This is what the demo does, because it needs no users table and it works across services that do not share one.&lt;/p&gt;

&lt;p&gt;What you should not do is the thing that happens by default: a string column that contains a number for people and a word for everyone else. That is a &lt;code&gt;Long&lt;/code&gt; with a lie in it, and every consumer of the column has to know about the lie.&lt;/p&gt;

&lt;p&gt;Whichever you pick, store a stable identifier rather than a display name, because names change over time, they get corrected, and sometimes they get anonymised, and none of that should propagate into an audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing it across microservices
&lt;/h2&gt;

&lt;p&gt;Everything above lives in one service. We have a dozen, and they all need the same four columns and the same &lt;code&gt;AuditableEntity&lt;/code&gt;, governed by the same rules. The natural move is a shared library, and the natural mistake is to put &lt;code&gt;AuditableEntity&lt;/code&gt; in the library and leave each service to write its own configuration.&lt;/p&gt;

&lt;p&gt;What the library should own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AuditableEntity&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Auditors&lt;/code&gt;, the values the library itself knows about (&lt;code&gt;system&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AuditorContext&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; itself&lt;/li&gt;
&lt;li&gt;a default &lt;code&gt;AuditorAware&lt;/code&gt; for services that have no notion of a user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What each service should own: its &lt;code&gt;AuditorAware&lt;/code&gt;, &lt;em&gt;if&lt;/em&gt; it has something better than the default to say. That is the whole contract.&lt;/p&gt;

&lt;p&gt;In Spring Boot terms, the library ships an auto-configuration:&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="nd"&gt;@AutoConfiguration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableJpaAuditing&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditingAutoConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@ConditionalOnMissingBean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditorAware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AuditorAware&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;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;systemAuditorAware&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&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="nc"&gt;Auditors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SYSTEM&lt;/span&gt;&lt;span class="o"&gt;));&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;registered in &lt;code&gt;META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;com.example.auditdemo.audit.AuditingAutoConfiguration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A web-facing service keeps its &lt;code&gt;CurrentUserAuditorAware&lt;/code&gt; as a &lt;code&gt;@Component&lt;/code&gt; and deletes its &lt;code&gt;JpaAuditingConfig&lt;/code&gt;; the &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; now lives in the library. Auto-configurations are evaluated after the service's own beans, so &lt;code&gt;@ConditionalOnMissingBean&lt;/code&gt; sees the service's bean and the default steps aside. A batch worker with no session does not need to declare anything: it gets the default, and its rows say whatever &lt;code&gt;AuditorContext&lt;/code&gt; was told, or &lt;code&gt;system&lt;/code&gt; when it was told nothing. No service ever writes &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last sentence is a rule, and Spring enforces it at startup. &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; registers its infrastructure beans by fixed name, and Boot has refused to silently override bean definitions since 2.1. A service that keeps its old configuration alongside the library's auto-configuration does not start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BeanDefinitionOverrideException: Invalid bean definition with name 'jpaAuditingHandler'
  defined in null: Cannot register bean definition [...] for bean 'jpaAuditingHandler'
  since there is already [...] bound.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defined in null&lt;/code&gt; is the tell, because it means the bean was registered by an annotation's registrar rather than by a class you can open in the IDE. When you see this one, the search term is &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;, and the answer is that it is declared twice.&lt;/p&gt;

&lt;p&gt;The same exception shows up in a second disguise. If an &lt;code&gt;AuditorAware&lt;/code&gt; implementation is annotated &lt;code&gt;@Component("auditorAware")&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; a configuration class has a &lt;code&gt;@Bean&lt;/code&gt; method called &lt;code&gt;auditorAware&lt;/code&gt; returning it (a common combination, because the &lt;code&gt;@Bean&lt;/code&gt; version gets added when someone reads about &lt;code&gt;auditorAwareRef&lt;/code&gt; and nobody removes the &lt;code&gt;@Component&lt;/code&gt;), the two definitions collide on the name, and one of them has to go; the &lt;code&gt;@Component&lt;/code&gt; on its own is enough.&lt;/p&gt;

&lt;p&gt;The demo repository is a single module, but the &lt;code&gt;audit&lt;/code&gt; package in it is wired exactly this way, imports file included, so you can see the mechanism working without a second &lt;code&gt;pom.xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The null column
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;getCurrentAuditor()&lt;/code&gt; returns an &lt;code&gt;Optional&lt;/code&gt; for a reason: &lt;code&gt;Optional.empty()&lt;/code&gt; means "unknown", and Spring Data leaves the column untouched. On insert, that means &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Whether that is acceptable is a schema question. With &lt;code&gt;created_by NOT NULL&lt;/code&gt;, an empty auditor turns into a constraint violation at flush time, several stack frames away from the auditor and with a message about the table rather than about the cause. We went with a sentinel (&lt;code&gt;system&lt;/code&gt;) instead of &lt;code&gt;empty()&lt;/code&gt; for that reason, and because a &lt;code&gt;NULL&lt;/code&gt; in an audit column is one more thing that "cannot happen" that will happen.&lt;/p&gt;

&lt;p&gt;Two related traps. Returning &lt;code&gt;null&lt;/code&gt; instead of an &lt;code&gt;Optional&lt;/code&gt; fails immediately with &lt;code&gt;Auditor must not be null&lt;/code&gt;, which is at least loud. And a user object that exists but has a null id (a half-initialised session) will produce the string &lt;code&gt;"null"&lt;/code&gt; if you build the value with &lt;code&gt;String.valueOf&lt;/code&gt;. Check that the id is present as well as the object before building the value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bulk queries
&lt;/h3&gt;

&lt;p&gt;This one is not a bug so much as a property of how JPA works, and it seems to catch everyone once. The nightly job above archives one entity at a time. Here is the same operation written as a bulk update instead:&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="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update Note n set n.archived = true where n.archived = false and n.createdAt &amp;lt; :before"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;bulkArchiveCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"before"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JPQL or native &lt;code&gt;UPDATE&lt;/code&gt; goes straight to the database. No entity is loaded, no &lt;code&gt;@PreUpdate&lt;/code&gt; fires, and the audit columns keep whatever they had. Run the job this way and it produces rows that say nothing about it: the test in the repo shows the &lt;code&gt;archived&lt;/code&gt; flag flipping while &lt;code&gt;updated_by&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; do not move. &lt;code&gt;deleteAllInBatch&lt;/code&gt; and every native query are the same story.&lt;/p&gt;

&lt;p&gt;Your options, in the order we usually pick them: load the entities and modify them one by one, as the job does (correct, slow past a few thousand rows); set the audit columns explicitly in the query (&lt;code&gt;set n.archived = true, n.updatedBy = :who, n.updatedAt = :now&lt;/code&gt;), which works but duplicates the auditor logic in every bulk query; or accept that bulk operations are unaudited at the row level and log them as one event at the job level. The wrong option is not deciding, which is what happens by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listeners that do nothing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@EntityListeners(AuditingEntityListener.class)&lt;/code&gt; on &lt;code&gt;AuditableEntity&lt;/code&gt; is what makes any of this happen, and it is inherited by every subclass, so you write it once. But the listener is only a hook. It delegates to an &lt;code&gt;AuditingHandler&lt;/code&gt; that &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; creates and injects, which works because Boot hands Hibernate a Spring-aware bean container, so the listener Hibernate instantiates is actually a Spring bean.&lt;/p&gt;

&lt;p&gt;Take &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; away and nothing fails. The listener still runs, but it finds no handler and simply returns, so every audit column on every row is &lt;code&gt;NULL&lt;/code&gt; and there is not a single log line to say why. We reproduced it in the demo by excluding the auto-configuration: &lt;code&gt;createdBy=null createdAt=null&lt;/code&gt;, test green.&lt;/p&gt;

&lt;p&gt;This is the failure mode to expect in a new service that pulled in the library but is missing something. The checklist, in order: is the listener on the entity or its superclass? Is &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; present, exactly once? Is there exactly one &lt;code&gt;AuditorAware&lt;/code&gt; bean? The first two fail silently; the third fails at startup, which is a mercy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Row-level audit columns tell you who last touched a row. They do not tell you what it looked like before, and they cannot be trusted to record every touch, as the bulk-query section shows. A real audit log is a separate concern: Hibernate Envers, a history table maintained by triggers, or change data capture off the transaction log. The four columns are the cheap first layer, and they are worth having precisely because they are cheap.&lt;/p&gt;

&lt;p&gt;What they are not worth having is half-configured. An auditor that returns &lt;code&gt;system&lt;/code&gt; from every path it does not understand will pass every test you write on a request thread, and then spend a year writing &lt;code&gt;system&lt;/code&gt; into rows that a specific person, or a specific job, was responsible for. The fix is not more fallback logic. It is making the code that knows who is acting say so, and making the fallback the exception it was supposed to be.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>jpa</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Where OpenTelemetry Stops: the missing 44ms in a Spring Boot trace</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:19:47 +0000</pubDate>
      <link>https://dev.to/purbano/where-opentelemetry-stops-the-missing-44ms-in-a-spring-boot-trace-ld9</link>
      <guid>https://dev.to/purbano/where-opentelemetry-stops-the-missing-44ms-in-a-spring-boot-trace-ld9</guid>
      <description>&lt;p&gt;A POST /orders in this demo takes 178 milliseconds. The OpenTelemetry Java agent, attached with a single JVM flag and no code changes, will tell you that 127 of those went to a downstream service and 6 to the database. It will not tell you anything about the remaining 44.&lt;/p&gt;

&lt;p&gt;That gap is where auto-instrumentation ends and your own code begins. This article locates that boundary precisely, then crosses it: with distributed tracing across two Spring Boot services with zero code changes, then with the minimum instrumentation needed to make the missing 44 milliseconds visible.&lt;/p&gt;

&lt;p&gt;Everything is in a companion repository: &lt;a href="https://github.com/PaoloUrbano/spring-boot-otel-demo" rel="noopener noreferrer"&gt;&lt;strong&gt;spring-boot-otel-demo&lt;/strong&gt;&lt;/a&gt;. If you have Docker installed you can clone it and have a live trace in front of you in about two minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt; OpenTelemetry Java agent 2.30.0, &lt;code&gt;opentelemetry-instrumentation-annotations&lt;/code&gt; 2.30.0, Spring Boot 3.5.16, Java 21, &lt;code&gt;otel/opentelemetry-collector-contrib:0.157.0&lt;/code&gt;, &lt;code&gt;jaegertracing/all-in-one:1.76.0&lt;/code&gt;, &lt;code&gt;postgres:18-alpine&lt;/code&gt;. This ecosystem moves fast. Check the current OpenTelemetry Java documentation before copying any version number.&lt;/p&gt;

&lt;p&gt;The repository has two branches on purpose. &lt;code&gt;master&lt;/code&gt; is the agent-only state described in Step 1: no OpenTelemetry dependency in either service. &lt;code&gt;manual-spans&lt;/code&gt; is the annotated version from Step 2. Clone, run &lt;code&gt;master&lt;/code&gt; first, then switch branches to see the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Here is a request that touches two services and a database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /orders&lt;/code&gt; arrives at &lt;code&gt;order-service&lt;/code&gt;. The service validates the request, calculates pricing, calls &lt;code&gt;inventory-service&lt;/code&gt; over HTTP to check stock, persists the order to PostgreSQL, and returns a response.&lt;/p&gt;

&lt;p&gt;It takes around 180 milliseconds. Sometimes it takes 900. Where does the time go?&lt;/p&gt;

&lt;p&gt;The usual answer is to open the logs of both services, filter by timestamp, and reconstruct the sequence by hand. That works with two services. It does not work with eight, and it does not work at all when two requests overlap in the same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traces, spans, and the one header that matters
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;trace&lt;/strong&gt; is one request through your whole system. A &lt;strong&gt;span&lt;/strong&gt; is one unit of work inside it: an HTTP handler, a database query, a method call. Spans nest: each has a parent, and the trace is a tree.&lt;/p&gt;

&lt;p&gt;Every span in a trace carries the same &lt;strong&gt;trace id&lt;/strong&gt;. That is what lets a UI stitch together work that happened in different processes, on different machines.&lt;/p&gt;

&lt;p&gt;The mechanism for carrying that id across a service boundary is a single HTTP header, &lt;code&gt;traceparent&lt;/code&gt;, defined by the W3C Trace Context specification. &lt;code&gt;order-service&lt;/code&gt; sends it, &lt;code&gt;inventory-service&lt;/code&gt; reads it, and the second service knows it belongs to a trace that started somewhere else.&lt;/p&gt;

&lt;p&gt;Here is the setup we are building:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0pd03rzedfcy6otdwco.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0pd03rzedfcy6otdwco.png" alt="Otel architecture" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the shape. The two services never talk to Jaeger. They talk to an &lt;strong&gt;OpenTelemetry Collector&lt;/strong&gt;, and the Collector decides where the data goes. We will come back to why that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: tracing with zero code changes
&lt;/h2&gt;

&lt;p&gt;The OpenTelemetry Java agent attaches to the JVM and rewrites bytecode at class-loading time. It recognises Spring MVC, JDBC, Hibernate and HTTP clients, and creates spans for them without the application knowing.&lt;/p&gt;

&lt;p&gt;Download the agent jar in your Dockerfile, then start the service with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["java", "-javaagent:/app/opentelemetry-javaagent.jar", "-jar", "/app/app.jar"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure it with environment variables in &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order-service&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://otel-collector:4317&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grpc&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_TRACES_EXPORTER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otlp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol line is load-bearing, not decoration. Port 4317 is the gRPC port and the Collector in this repo opens a gRPC receiver only. Defaults for this variable have varied across SDKs and versions, so set it explicitly: if the agent picks &lt;code&gt;http/protobuf&lt;/code&gt; and sends it to 4317, every export fails and no traces reach Jaeger.&lt;/p&gt;

&lt;p&gt;The failure is at least loud. The agent logs it to the service's own stdout on every export cycle, naming both the exporter and the endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[otel.javaagent] ... ERROR io.opentelemetry.exporter.otlp.internal.HttpExporter
- Failed to export spans. The request could not be executed.
java.net.SocketException: Connection reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Jaeger is empty, &lt;code&gt;docker compose logs order-service&lt;/code&gt; is the first place to look. The word &lt;code&gt;HttpExporter&lt;/code&gt; against a port you configured for gRPC is the whole diagnosis.&lt;/p&gt;

&lt;p&gt;That is the entire integration. No dependency in &lt;code&gt;pom.xml&lt;/code&gt;, no configuration class, no SDK.&lt;/p&gt;

&lt;p&gt;Bring the stack up and send a request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"customerId":"CUST-001","sku":"SKU-1000","quantity":5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open Jaeger at &lt;code&gt;http://localhost:16686&lt;/code&gt;, select &lt;code&gt;order-service&lt;/code&gt;, and click Find Traces:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgs2txkuvj37shhel4itn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgs2txkuvj37shhel4itn.png" alt="Agent only" width="800" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One thing before you read anything into the numbers: let the JVM warm up. The first request through a fresh service can be three times slower than the steady state, and the shape of that first trace is misleading. Send the request ten times and look at the last one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you got for free
&lt;/h2&gt;

&lt;p&gt;Read that waterfall carefully, because there is more in it than it first appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trace crosses the service boundary.&lt;/strong&gt; &lt;code&gt;inventory-service GET /inventory/{sku}&lt;/code&gt; is nested inside the client span of &lt;code&gt;order-service&lt;/code&gt;. Two JVMs, two containers, one trace. Nobody wrote code to make that happen. The agent injected &lt;code&gt;traceparent&lt;/code&gt; on the way out and read it on the way in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The persistence layer is broken down for you.&lt;/strong&gt; &lt;code&gt;OrderRepository.save&lt;/code&gt; at 5.93ms contains &lt;code&gt;Session.persist&lt;/code&gt; at 1.36ms, the actual &lt;code&gt;INSERT&lt;/code&gt; at 496µs, and &lt;code&gt;Transaction.commit&lt;/code&gt; at 3.37ms.&lt;/p&gt;

&lt;p&gt;That last pair is worth sitting with. The &lt;code&gt;INSERT&lt;/code&gt; is under half a millisecond. The commit costs nearly seven times as much. Most of the time spent persisting this order is not the query. It is everything around it. You would never learn that from a log line, and it is the kind of detail that changes how you think about transaction boundaries under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self time versus total time.&lt;/strong&gt; The client span for the inventory call is 127.37ms, while the server span inside &lt;code&gt;inventory-service&lt;/code&gt; is 123.69ms. The difference is real client-side and network overhead. When hunting a bottleneck, the number you want is not the widest bar, but the bar that is wide &lt;em&gt;and does not have equally wide children&lt;/em&gt;. A service that takes two seconds is not guilty if 1.9 of them are spent waiting for someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the agent stops
&lt;/h2&gt;

&lt;p&gt;Now look at the start of the trace:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf7s5et1zty0smwat8qv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf7s5et1zty0smwat8qv.png" alt="Agent only zoom" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The root span starts at zero. The first child span starts around 44 milliseconds later. Nothing at all is recorded in between.&lt;/p&gt;

&lt;p&gt;That gap is your business logic. In this service it is request validation followed by a pricing calculation: plain Java, no I/O. The DTO mapping that happens after persistence is invisible for the same reason, though at well under a millisecond it does not leave a hole you can see.&lt;/p&gt;

&lt;p&gt;And that is exactly why the agent cannot see them. Auto-instrumentation works by recognising &lt;strong&gt;libraries&lt;/strong&gt;: a servlet container, a JDBC driver, an HTTP client. It has no way to know that &lt;code&gt;computePricing&lt;/code&gt; is a meaningful unit of work in your domain. From the agent's point of view, the time between two library calls is just time.&lt;/p&gt;

&lt;p&gt;This is the honest limit of "zero code changes". The agent gives you the skeleton of the request for free. The flesh is your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: naming your own work
&lt;/h2&gt;

&lt;p&gt;Add one dependency to &lt;code&gt;order-service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;properties&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Must match the agent version pinned in the Dockerfile --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;opentelemetry-instrumentation.version&amp;gt;&lt;/span&gt;2.30.0&lt;span class="nt"&gt;&amp;lt;/opentelemetry-instrumentation.version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/properties&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.opentelemetry.instrumentation&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;opentelemetry-instrumentation-annotations&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${opentelemetry-instrumentation.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that version aligned with the agent jar you download in the Dockerfile. A mismatch between the two is the most common reason &lt;code&gt;@WithSpan&lt;/code&gt; spans silently fail to appear. The build succeeds, the service starts, and the spans simply are not there.&lt;/p&gt;

&lt;p&gt;Then annotate the methods that matter. &lt;code&gt;@WithSpan&lt;/code&gt; creates a span for the method, correctly parented to whatever span is active. &lt;code&gt;@SpanAttribute&lt;/code&gt; attaches a method parameter to that span as a searchable field:&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="nd"&gt;@WithSpan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"calculate-pricing"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;PricingBreakdown&lt;/span&gt; &lt;span class="nf"&gt;computePricing&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@SpanAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.sku"&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;sku&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                        &lt;span class="nd"&gt;@SpanAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.quantity"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// both helpers are plain in-memory work: a price list scan and a discount tier walk&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lookupBasePrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;discountRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolveVolumeDiscountRate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LINE_SIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remainingUnits&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;applyPerUnitRules&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discountRate&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subtract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineDiscount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineTax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TAX_RATE&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;taxAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineTax&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;totalPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PricingBreakdown&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discountRate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalPrice&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;No I/O, no sleeps, no database. Pure CPU work, which is exactly the point. This is the kind of method the agent cannot see.&lt;/p&gt;

&lt;p&gt;One disclosure, since the numbers have to add up: the loop you see here is cheap at &lt;code&gt;quantity = 5&lt;/code&gt;. The weight sits in &lt;code&gt;lookupBasePrice&lt;/code&gt;, which builds and linearly scans a price catalog of roughly 950,000 entries, once per call rather than once per unit. That is deliberately absurd. In a real service it would be a cached lookup and the span would measure microseconds. The point of this article is the shape of the trace, not the size of the number, and a gap of forty milliseconds is easier to see in a screenshot than a gap of forty microseconds.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before scattering these around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works on private methods, but only because the agent is rewriting bytecode.&lt;/strong&gt; This is not a Spring proxy, so there is no self-invocation problem to worry about. A private method called from within the same class still gets its span. If one fails to appear, check the version alignment above first, then try widening the method's visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract before you annotate.&lt;/strong&gt; A span is only useful if it wraps one coherent thing. If your pricing logic sits inline in the middle of a 200-line service method, annotating that method tells you nothing you did not already know.&lt;/p&gt;

&lt;p&gt;Switch to the &lt;code&gt;manual-spans&lt;/code&gt; branch, rebuild, send the same request, and the gap is gone:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0ydnzwif3o01wjh1e6z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0ydnzwif3o01wjh1e6z.png" alt="Manual-spans" width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;validate-order-request&lt;/code&gt; at 32µs. &lt;code&gt;calculate-pricing&lt;/code&gt; at 38.68ms, most of the 44ms gap from before, now with a name. &lt;code&gt;check-inventory&lt;/code&gt; and &lt;code&gt;persist-order&lt;/code&gt; wrap the library spans they own, and &lt;code&gt;map-response&lt;/code&gt; closes the request at 20µs.&lt;/p&gt;

&lt;p&gt;Note that a few milliseconds are still unaccounted for. That residue is not a failure of instrumentation: some of what sits between two spans is request deserialisation, framework dispatch and other work that is not yours to name. You are never going to close the gap completely, and chasing the last microsecond is how you end up with a trace so noisy that nobody reads it.&lt;/p&gt;

&lt;p&gt;The totals: 178.06ms without manual spans, 184.86ms with them. Do not read that difference as the cost of instrumentation. Two annotations cannot cost seven milliseconds. The runtime overhead of a span is a context push and a timestamp, measured in fractions of a microsecond. What you are looking at is JVM noise: a minor GC, a scheduling hiccup, network jitter between two containers. Run the same request twenty times and you will see a wider spread than this with no code change at all.&lt;/p&gt;

&lt;p&gt;There is one real cost worth naming: annotating a small private method can prevent the JIT from inlining it, so a hot, tight method may genuinely run slower once instrumented. That is an argument for instrumenting meaningful units of work rather than every method you can reach. It is not an argument against instrumenting at all. In an I/O bound service like this one, where a single HTTP call accounts for 70% of the request, it is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attributes are where this gets useful
&lt;/h2&gt;

&lt;p&gt;Click on the &lt;code&gt;calculate-pricing&lt;/code&gt; span:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qdo76kzjjbiuim20k5c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qdo76kzjjbiuim20k5c.png" alt="Span attributes" width="799" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The span carries &lt;code&gt;order.sku = SKU-1000&lt;/code&gt; and &lt;code&gt;order.quantity = 5&lt;/code&gt;, the actual values from the request. It also carries &lt;code&gt;code.function = computePricing&lt;/code&gt; and &lt;code&gt;code.namespace = com.example.orderservice.order.OrderService&lt;/code&gt;, added automatically.&lt;/p&gt;

&lt;p&gt;This is the difference between &lt;em&gt;something took 38ms&lt;/em&gt; and &lt;em&gt;&lt;code&gt;OrderService.computePricing&lt;/code&gt; took 38ms for SKU-1000 at quantity 5&lt;/em&gt;. Once those attributes are on the span you can search on them: every pricing calculation above a threshold, filtered by SKU. That is a question you cannot ask a log file without writing a parser first.&lt;/p&gt;

&lt;p&gt;Two cautions. Whatever you put in an attribute ends up in your observability backend, so keep personal data and secrets out of them. And be careful about where that attribute travels: &lt;code&gt;order.sku&lt;/code&gt; is harmless on a span, because traces are sampled and stored as individual records. Put the same field on a &lt;strong&gt;metric&lt;/strong&gt; and you have created one time series per SKU. High-cardinality labels are the classic way to take down a Prometheus instance, and the mistake is easy to make once you start instrumenting by habit rather than by intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Collector is in the picture
&lt;/h2&gt;

&lt;p&gt;You may have noticed the agent could have pointed straight at Jaeger. In development it can. There are three reasons not to do that anywhere else.&lt;/p&gt;

&lt;p&gt;Batching and retries belong in a separate process, not in the request path of your application. The Collector can enrich, filter and drop data centrally rather than in every service. And most importantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jaeger:4317&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing your observability backend means editing that block. It does not mean touching, rebuilding or redeploying a single service. Your applications emit OTLP, an open protocol, and where it lands is a deployment detail.&lt;/p&gt;

&lt;p&gt;If you have ever had to argue an architecture decision on vendor lock-in grounds, this is an unusually clean example: the instrumentation is standard, the wire protocol is standard, and the vendor-specific part is one block in one file.&lt;/p&gt;

&lt;p&gt;For completeness: OTLP travels over gRPC on port 4317 and over HTTP/protobuf on 4318. The specification says the default should be &lt;code&gt;http/protobuf&lt;/code&gt; unless an SDK has good reason to pick gRPC (backward compatibility, typically), so what you actually get depends on your SDK and version. Pin it explicitly, as in the compose file above. gRPC is generally the right choice for agent-to-collector traffic, which is high volume and continuous. Switch to HTTP if you have proxies or load balancers in between that are unhappy with long-lived HTTP/2 connections, which is common enough in enterprise networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Tracing every request is affordable on a laptop but not in production, so sampling strategy is the decision waiting on the other side of this setup. Traces are also only one of three signals: metrics and logs travel the same pipeline, and correlating existing Log4j2 output with trace ids is arguably the highest-value thing to do after this. Asynchronous boundaries deserve their own article: put a message on a queue and the context does not propagate on its own, so the trace breaks silently at exactly that point.&lt;/p&gt;

&lt;p&gt;The boundary described here, though, does not move. Auto-instrumentation maps the parts of your system that are made of libraries. Everything that is genuinely yours (the logic you were actually hired to write) stays invisible until you name it.&lt;/p&gt;

&lt;p&gt;Naming it costs one dependency and a handful of annotations.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>opentelemetry</category>
      <category>observability</category>
    </item>
  </channel>
</rss>
