This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new b0717c3a49 [#12691] feat(core): let inner dispatchers contribute 
customInfo to table events (#12692)
b0717c3a49 is described below

commit b0717c3a4949420a1004c638338cc6b6d5e40302
Author: Nevin Zheng <[email protected]>
AuthorDate: Fri Aug 28 23:55:20 2026 -0700

    [#12691] feat(core): let inner dispatchers contribute customInfo to table 
events (#12692)
    
    ## Summary
    
    An inner `TableDispatcher` layer can now attach `Map<String, String>`
    audit facts to the terminal table event that `TableEventDispatcher`
    already emits, so one operation still produces one audit record and that
    record can say what the dispatch chain knew about it.
    
    The change is deliberately confined to carrying facts. Dispatch
    semantics are untouched: events still go out through
    `eventBus.dispatchEvent`, and a listener exception propagates exactly as
    it does on `main`, whether or not extras were stashed.
    
    ## Context
    
    Gravitino's dispatch chain is layered, and the layer that knows
    something audit-worthy about an operation is usually not the layer that
    emits the event. An inner layer that applied a policy or enforced a
    validation had no way to get that fact into the `CreateTableEvent` the
    outer dispatcher publishes — it could publish a separate event, which
    breaks the one-operation-one-event shape audit consumers rely on, or
    drop the information.
    
    This fills a gap in a facility the event API already has rather than
    proposing a new one. `BaseEvent` declares `customInfo()` returning an
    empty map, marked `@since 0.8.0`
    
(`core/src/main/java/org/apache/gravitino/listener/api/event/BaseEvent.java:131`).
    `AuthorizationDenialFailureEvent` and `HttpRequestFailureEvent` already
    override it, both cases where the reason for an outcome was the point.
    Both audit formatters already serialize it with redaction
    (`audit/JsonAuditFormatter.java:69`,
    `audit/v2/SimpleAuditLogV2.java:101`, `audit/AuditLogRedactor.java`).
    
    The consumption side was therefore already built end to end. What was
    missing is that only the code constructing an event can populate
    `customInfo`, and for table events that is always the outermost
    dispatcher. No listener or formatter changes are needed.
    
    ## Changes
    
    - **New**: `TableEvent` and `TableFailureEvent` gain a `protected`
    constructor accepting custom info and an override of `customInfo()` that
    returns it, defensively copied and normalized so `null` and empty both
    yield an empty map. The six concrete table events gain a matching
    `public` overload.
    - **New**: `RequestContext.setAuditExtras` and `takeAuditExtras`. A
    contributing layer stashes facts before it returns or throws;
    `TableEventDispatcher` takes them when building the terminal event. The
    read is read-and-clear, and `RequestContext.clear()` now removes the
    stash during request teardown, so a fact cannot follow a pooled thread
    into a later request.
    - **Unchanged**: every existing constructor keeps its signature and
    delegates with an empty map. `EventBus` is untouched. Error propagation
    and operation results are untouched.
    - **Deferred**: the other fourteen dispatchers (`Catalog`, `Schema`,
    `Fileset`, `Topic`, `Model`, `View`, `Function`, `Partition`, `Policy`,
    `Tag`, `Statistic`, `Job`, `Metalake`, `AccessControl`) have the same
    gap. Kept out to keep this reviewable.
    
    ## Impact and risks
    
    No behavior change for existing deployments and no configuration change.
    The API surface is additive only: new constructor overloads on the six
    table events, new protected constructors on the two base classes, and
    the two `RequestContext` methods. Existing constructors now produce
    events whose `customInfo()` is an empty map, which is what `BaseEvent`
    already returned for them.
    
    Audit output changes only when a layer actually stashes facts. In that
    case the existing `customInfo` field is populated instead of empty; the
    record's shape, count, and every other field are unchanged.
    
    The risk worth naming is the threading contract — see below.
    
    ## Reviewer focus
    
    **The `ThreadLocal` is the debatable part and the thing I most want a
    read on.** Passing the facts as an argument would be cleaner, but the
    contributing layer and the emitting layer are not in the same call
    frame: `TableEventDispatcher.createTable` calls
    `dispatcher.createTable(...)`, gets back a `Table`, then builds the
    event. Threading a value through means changing the `TableDispatcher`
    interface so every implementation returns or accepts an audit channel —
    a wide blast radius for a facility most implementations will never use.
    `RequestContext` already holds per-request state of exactly this shape,
    so this reuses it rather than adding a parallel mechanism.
    
    The cost is real: correctness depends on the contributor and the
    dispatcher running on the same thread. That holds for the servlet
    request path, but it is an invariant a future async dispatch layer could
    break. `TestRequestContext` pins the thread-confinement assumption
    explicitly so the constraint is visible and a violation fails loudly
    rather than silently mis-attributing a fact.
    
    Second, scope. If you would rather see the pattern applied across all
    fifteen dispatchers in one change than land it on tables first, say so
    and I will extend it.
    
    ## Verification
    
    Environment: macOS, Gradle 8.2 wrapper, Amazon Corretto JDK 17.0.19. All
    from the repository root.
    
    - `./gradlew :core:compileJava :core:compileTestJava` — BUILD
    SUCCESSFUL.
    - `./gradlew :core:test -PskipITs --tests '*TableEventDispatcher*'
    --tests '*TestTableEvent*' --tests '*TestRequestContext*' --tests
    '*TestOperation*'` — BUILD SUCCESSFUL. Confirmed against the JUnit XML
    that the tests ran rather than being filtered out: 41 tests, 0 failures,
    0 errors (`TestTableEventDispatcher` 3, `TestTableEvent` 16,
    `TestRequestContext` 9, `TestOperation` 13; the latter three include
    pre-existing tests in those classes).
    - `./gradlew spotlessApply` then `./gradlew spotlessJavaCheck` — BUILD
    SUCCESSFUL, no drift.
    
    Coverage added:
    
    - `TestTableEventDispatcher` (new): stashed facts reach
    `CreateTableEvent.customInfo()` with exactly one post event produced;
    facts do not leak into a second operation on the same thread; and
    listener-failure propagation is identical with and without extras, which
    is the assertion that would catch this change starting to affect error
    handling.
    - `TestTableEvent`: success, failure, and alter/load cases driven
    through a real dispatcher, asserting empty `customInfo()` on the
    pre-existing cases so the default stays empty.
    - `TestRequestContext`: set/take/clear semantics, empty-and-null
    retraction, and thread confinement of the stash.
    - `TestOperation`: events built with the new constructors still classify
    as `CREATE_TABLE`.
    
    **Not run**: the full `./gradlew build`, integration tests, and other
    modules' suites. The change is confined to `core`; CI is the check for
    the rest.
    
    ## Related work
    
    Fix: #12691
    
    cc @roryqi @jerryshao @lasdf for review.
    
    Nevin
    Sent from my 🤖 (Cursor)
---
 .../gravitino/listener/TableEventDispatcher.java   |  49 +++++-
 .../listener/api/event/AlterTableEvent.java        |  22 ++-
 .../listener/api/event/AlterTableFailureEvent.java |  21 ++-
 .../listener/api/event/CreateTableEvent.java       |  19 ++-
 .../api/event/CreateTableFailureEvent.java         |  21 ++-
 .../listener/api/event/LoadTableEvent.java         |  16 +-
 .../listener/api/event/LoadTableFailureEvent.java  |  16 +-
 .../gravitino/listener/api/event/TableEvent.java   |  25 +++
 .../listener/api/event/TableFailureEvent.java      |  27 ++++
 .../org/apache/gravitino/utils/RequestContext.java |  38 ++++-
 .../org/apache/gravitino/audit/TestOperation.java  |  22 +++
 .../listener/TestTableEventDispatcher.java         | 170 +++++++++++++++++++++
 .../listener/api/event/TestTableEvent.java         |  85 +++++++++++
 .../apache/gravitino/utils/TestRequestContext.java |  73 +++++++++
 14 files changed, 589 insertions(+), 15 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java 
b/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
index 435322ec08..fd235bb508 100644
--- a/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
@@ -54,12 +54,17 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.RequestContext;
 
 /**
  * {@code TableEventDispatcher} is a decorator for {@link TableDispatcher} 
that not only delegates
  * table operations to the underlying catalog dispatcher but also dispatches 
corresponding events to
  * an {@link org.apache.gravitino.listener.EventBus} after each operation is 
completed. This allows
  * for event-driven workflows or monitoring of table operations.
+ *
+ * <p>Create, alter, and load attach optional extras stashed on {@link 
RequestContext} so an inner
+ * dispatcher can contribute {@code customInfo} to the terminal event without 
publishing a sibling
+ * event that consumers would have to correlate.
  */
 public class TableEventDispatcher implements TableDispatcher {
 
@@ -101,12 +106,19 @@ public class TableEventDispatcher implements 
TableDispatcher {
     eventBus.dispatchEvent(new 
LoadTablePreEvent(PrincipalUtils.getCurrentUserName(), ident));
     try {
       Table table = dispatcher.loadTable(ident);
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new LoadTableEvent(PrincipalUtils.getCurrentUserName(), ident, new 
TableInfo(table)));
+          extras.isEmpty()
+              ? new LoadTableEvent(PrincipalUtils.getCurrentUserName(), ident, 
new TableInfo(table))
+              : new LoadTableEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, new 
TableInfo(table), extras));
       return table;
     } catch (Exception e) {
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(), 
ident, e));
+          extras.isEmpty()
+              ? new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(), 
ident, e)
+              : new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(), 
ident, e, extras));
       throw e;
     }
   }
@@ -139,13 +151,22 @@ public class TableEventDispatcher implements 
TableDispatcher {
       Table table =
           dispatcher.createTable(
               ident, columns, comment, properties, partitions, distribution, 
sortOrders, indexes);
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new CreateTableEvent(PrincipalUtils.getCurrentUserName(), ident, new 
TableInfo(table)));
+          extras.isEmpty()
+              ? new CreateTableEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, new 
TableInfo(table))
+              : new CreateTableEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, new 
TableInfo(table), extras));
       return table;
     } catch (Exception e) {
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new CreateTableFailureEvent(
-              PrincipalUtils.getCurrentUserName(), ident, e, 
createTableRequest));
+          extras.isEmpty()
+              ? new CreateTableFailureEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, e, 
createTableRequest)
+              : new CreateTableFailureEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, e, 
createTableRequest, extras));
       throw e;
     }
   }
@@ -157,13 +178,25 @@ public class TableEventDispatcher implements 
TableDispatcher {
         new AlterTablePreEvent(PrincipalUtils.getCurrentUserName(), ident, 
changes));
     try {
       Table table = dispatcher.alterTable(ident, changes);
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new AlterTableEvent(
-              PrincipalUtils.getCurrentUserName(), ident, changes, new 
TableInfo(table)));
+          extras.isEmpty()
+              ? new AlterTableEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, changes, new 
TableInfo(table))
+              : new AlterTableEvent(
+                  PrincipalUtils.getCurrentUserName(),
+                  ident,
+                  changes,
+                  new TableInfo(table),
+                  extras));
       return table;
     } catch (Exception e) {
+      Map<String, String> extras = RequestContext.takeAuditExtras();
       eventBus.dispatchEvent(
-          new AlterTableFailureEvent(PrincipalUtils.getCurrentUserName(), 
ident, e, changes));
+          extras.isEmpty()
+              ? new 
AlterTableFailureEvent(PrincipalUtils.getCurrentUserName(), ident, e, changes)
+              : new AlterTableFailureEvent(
+                  PrincipalUtils.getCurrentUserName(), ident, e, changes, 
extras));
       throw e;
     }
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
index fd6365a71c..da57c5e9cb 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 import org.apache.gravitino.listener.api.info.TableInfo;
@@ -46,7 +47,26 @@ public final class AlterTableEvent extends TableEvent {
       NameIdentifier identifier,
       TableChange[] tableChanges,
       TableInfo updatedTableInfo) {
-    super(user, identifier);
+    this(user, identifier, tableChanges, updatedTableInfo, null);
+  }
+
+  /**
+   * Constructs an instance of {@code AlterTableEvent} with optional audit 
extras.
+   *
+   * @param user The username of the individual responsible for initiating the 
table alteration.
+   * @param identifier The unique identifier of the altered table.
+   * @param tableChanges An array of {@link TableChange} objects representing 
the specific changes
+   *     applied to the table during the alteration process.
+   * @param updatedTableInfo The post-alteration state of the table.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public AlterTableEvent(
+      String user,
+      NameIdentifier identifier,
+      TableChange[] tableChanges,
+      TableInfo updatedTableInfo,
+      Map<String, String> customInfo) {
+    super(user, identifier, customInfo);
     this.tableChanges = tableChanges.clone();
     this.updatedTableInfo = updatedTableInfo;
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
index 84a998adb8..69d5916840 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 import org.apache.gravitino.rel.TableChange;
@@ -41,7 +42,25 @@ public final class AlterTableFailureEvent extends 
TableFailureEvent {
    */
   public AlterTableFailureEvent(
       String user, NameIdentifier identifier, Exception exception, 
TableChange[] tableChanges) {
-    super(user, identifier, exception);
+    this(user, identifier, exception, tableChanges, null);
+  }
+
+  /**
+   * Constructs an {@code AlterTableFailureEvent} with optional audit extras.
+   *
+   * @param user The user who initiated the table alteration operation.
+   * @param identifier The identifier of the table that was attempted to be 
altered.
+   * @param exception The exception that was thrown during the table 
alteration operation.
+   * @param tableChanges The changes that were attempted on the table.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public AlterTableFailureEvent(
+      String user,
+      NameIdentifier identifier,
+      Exception exception,
+      TableChange[] tableChanges,
+      Map<String, String> customInfo) {
+    super(user, identifier, exception, customInfo);
     this.tableChanges = tableChanges.clone();
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
index 482162c49d..419ca9d874 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 import org.apache.gravitino.listener.api.info.TableInfo;
@@ -37,7 +38,23 @@ public final class CreateTableEvent extends TableEvent {
    * @param createdTableInfo The final state of the table post-creation.
    */
   public CreateTableEvent(String user, NameIdentifier identifier, TableInfo 
createdTableInfo) {
-    super(user, identifier);
+    this(user, identifier, createdTableInfo, null);
+  }
+
+  /**
+   * Constructs an instance of {@code CreateTableEvent} with optional audit 
extras.
+   *
+   * @param user The username of the individual who initiated the table 
creation.
+   * @param identifier The unique identifier of the table that was created.
+   * @param createdTableInfo The final state of the table post-creation.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public CreateTableEvent(
+      String user,
+      NameIdentifier identifier,
+      TableInfo createdTableInfo,
+      Map<String, String> customInfo) {
+    super(user, identifier, customInfo);
     this.createdTableInfo = createdTableInfo;
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
index 6bb27ce922..17470cd636 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 import org.apache.gravitino.listener.api.info.TableInfo;
@@ -45,7 +46,25 @@ public final class CreateTableFailureEvent extends 
TableFailureEvent {
    */
   public CreateTableFailureEvent(
       String user, NameIdentifier identifier, Exception exception, TableInfo 
createTableRequest) {
-    super(user, identifier, exception);
+    this(user, identifier, exception, createTableRequest, null);
+  }
+
+  /**
+   * Constructs a {@code CreateTableFailureEvent} with optional audit extras.
+   *
+   * @param user The user who initiated the table creation operation.
+   * @param identifier The identifier of the table that was attempted to be 
created.
+   * @param exception The exception that was thrown during the table creation 
operation.
+   * @param createTableRequest The original request information used to 
attempt to create the table.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public CreateTableFailureEvent(
+      String user,
+      NameIdentifier identifier,
+      Exception exception,
+      TableInfo createTableRequest,
+      Map<String, String> customInfo) {
+    super(user, identifier, exception, customInfo);
     this.createTableRequest = createTableRequest;
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
index 5ca5ab35fd..003109553b 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 import org.apache.gravitino.listener.api.info.TableInfo;
@@ -36,7 +37,20 @@ public final class LoadTableEvent extends TableEvent {
    * @param tableInfo The state of the table post-loading.
    */
   public LoadTableEvent(String user, NameIdentifier identifier, TableInfo 
tableInfo) {
-    super(user, identifier);
+    this(user, identifier, tableInfo, null);
+  }
+
+  /**
+   * Constructs an instance of {@code LoadTableEvent} with optional audit 
extras.
+   *
+   * @param user The username of the individual who initiated the table 
loading.
+   * @param identifier The unique identifier of the table that was loaded.
+   * @param tableInfo The state of the table post-loading.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public LoadTableEvent(
+      String user, NameIdentifier identifier, TableInfo tableInfo, Map<String, 
String> customInfo) {
+    super(user, identifier, customInfo);
     this.loadedTableInfo = tableInfo;
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
index 1c6e6e3eff..df12ad6b43 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
@@ -19,6 +19,7 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 
@@ -34,7 +35,20 @@ public final class LoadTableFailureEvent extends 
TableFailureEvent {
    *     insight into the issues encountered.
    */
   public LoadTableFailureEvent(String user, NameIdentifier identifier, 
Exception exception) {
-    super(user, identifier, exception);
+    this(user, identifier, exception, null);
+  }
+
+  /**
+   * Constructs a {@code LoadTableFailureEvent} with optional audit extras.
+   *
+   * @param user The user who initiated the table loading operation.
+   * @param identifier The identifier of the table that the loading attempt 
was made for.
+   * @param exception The exception that was thrown during the table loading 
operation.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  public LoadTableFailureEvent(
+      String user, NameIdentifier identifier, Exception exception, Map<String, 
String> customInfo) {
+    super(user, identifier, exception, customInfo);
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java 
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
index 32407ed62c..1574dbbcac 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
@@ -19,6 +19,8 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 
@@ -33,6 +35,8 @@ import org.apache.gravitino.annotation.DeveloperApi;
  */
 @DeveloperApi
 public abstract class TableEvent extends Event {
+  private final Map<String, String> customInfo;
+
   /**
    * Constructs a new {@code TableEvent} with the specified user and table 
identifier.
    *
@@ -41,11 +45,32 @@ public abstract class TableEvent extends Event {
    *     details such as the metalake, catalog, schema, and table name.
    */
   protected TableEvent(String user, NameIdentifier identifier) {
+    this(user, identifier, ImmutableMap.of());
+  }
+
+  /**
+   * Constructs a new {@code TableEvent} with optional audit extras.
+   *
+   * @param user The user responsible for triggering the table operation.
+   * @param identifier The identifier of the table involved in the operation.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  protected TableEvent(String user, NameIdentifier identifier, Map<String, 
String> customInfo) {
     super(user, identifier);
+    this.customInfo =
+        customInfo == null || customInfo.isEmpty()
+            ? ImmutableMap.of()
+            : ImmutableMap.copyOf(customInfo);
   }
 
   @Override
   public OperationStatus operationStatus() {
     return OperationStatus.SUCCESS;
   }
+
+  /** {@inheritDoc} */
+  @Override
+  public Map<String, String> customInfo() {
+    return customInfo;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
 
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
index a1293d523f..d8ad097ad0 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
@@ -19,6 +19,8 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.annotation.DeveloperApi;
 
@@ -34,6 +36,8 @@ import org.apache.gravitino.annotation.DeveloperApi;
  */
 @DeveloperApi
 public abstract class TableFailureEvent extends FailureEvent {
+  private final Map<String, String> customInfo;
+
   /**
    * Constructs a new {@code TableFailureEvent} instance, capturing 
information about the failed
    * table operation.
@@ -44,6 +48,29 @@ public abstract class TableFailureEvent extends FailureEvent 
{
    *     of the failure.
    */
   protected TableFailureEvent(String user, NameIdentifier identifier, 
Exception exception) {
+    this(user, identifier, exception, ImmutableMap.of());
+  }
+
+  /**
+   * Constructs a new {@code TableFailureEvent} with optional audit extras.
+   *
+   * @param user The user associated with the failed table operation.
+   * @param identifier The identifier of the table that was involved in the 
failed operation.
+   * @param exception The exception that was thrown during the table operation.
+   * @param customInfo optional audit facts contributed by an inner dispatcher
+   */
+  protected TableFailureEvent(
+      String user, NameIdentifier identifier, Exception exception, Map<String, 
String> customInfo) {
     super(user, identifier, exception);
+    this.customInfo =
+        customInfo == null || customInfo.isEmpty()
+            ? ImmutableMap.of()
+            : ImmutableMap.copyOf(customInfo);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public Map<String, String> customInfo() {
+    return customInfo;
   }
 }
diff --git a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java 
b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
index bfdbb9c60e..e674dba595 100644
--- a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
+++ b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
@@ -19,11 +19,14 @@
 
 package org.apache.gravitino.utils;
 
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+
 /**
  * Holds per-request context data in a {@link ThreadLocal} so that event 
classes constructed on the
  * servlet thread can capture it without carrying a servlet dependency.
  *
- * <p>Currently tracks two pieces of state:
+ * <p>Currently tracks three pieces of state:
  *
  * <ul>
  *   <li><b>remoteAddress</b> — the client IP resolved from {@code 
X-Forwarded-For} or {@link
@@ -33,6 +36,8 @@ package org.apache.gravitino.utils;
  *       org.apache.gravitino.listener.api.event.FailureEvent} is dispatched, 
so that {@code
  *       HttpAuditFilter} can skip emitting a redundant HTTP-level failure 
event for the same
  *       request.
+ *   <li><b>auditExtras</b> — optional {@code customInfo} facts stashed by an 
inner dispatcher and
+ *       consumed by the outer event dispatcher so one operation still 
produces one event.
  * </ul>
  *
  * <p><b>Threading contract:</b> values must be set and cleared on the same 
(servlet) thread. Event
@@ -43,6 +48,7 @@ public class RequestContext {
 
   private static final ThreadLocal<String> REMOTE_ADDRESS = new 
ThreadLocal<>();
   private static final ThreadLocal<Boolean> OPERATION_FAILURE_FIRED = new 
ThreadLocal<>();
+  private static final ThreadLocal<Map<String, String>> AUDIT_EXTRAS = new 
ThreadLocal<>();
 
   private RequestContext() {}
 
@@ -93,6 +99,35 @@ public class RequestContext {
     OPERATION_FAILURE_FIRED.remove();
   }
 
+  /**
+   * Stashes optional audit extras for the current request thread. An inner 
dispatcher calls this
+   * before returning or throwing so the outer event dispatcher can attach the 
extras to the
+   * existing table event.
+   *
+   * <p>A {@code null} or empty map clears any previously stashed extras.
+   *
+   * @param extras optional {@code customInfo} facts, or {@code null} to clear
+   */
+  public static void setAuditExtras(Map<String, String> extras) {
+    if (extras == null || extras.isEmpty()) {
+      AUDIT_EXTRAS.remove();
+      return;
+    }
+    AUDIT_EXTRAS.set(ImmutableMap.copyOf(extras));
+  }
+
+  /**
+   * Returns and clears audit extras stashed on this thread. The outer event 
dispatcher calls this
+   * when constructing the terminal table event.
+   *
+   * @return an immutable extras map, or an empty map when none were stashed
+   */
+  public static Map<String, String> takeAuditExtras() {
+    Map<String, String> extras = AUDIT_EXTRAS.get();
+    AUDIT_EXTRAS.remove();
+    return extras == null ? ImmutableMap.of() : extras;
+  }
+
   /**
    * Removes all per-request bindings from the current thread. Must be called 
in a {@code finally}
    * block after the request completes to prevent thread-pool leaks.
@@ -100,5 +135,6 @@ public class RequestContext {
   public static void clear() {
     REMOTE_ADDRESS.remove();
     OPERATION_FAILURE_FIRED.remove();
+    AUDIT_EXTRAS.remove();
   }
 }
diff --git a/core/src/test/java/org/apache/gravitino/audit/TestOperation.java 
b/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
index 5531da02fe..7dca8dfa49 100644
--- a/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
+++ b/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
@@ -607,6 +607,28 @@ public class TestOperation {
         AuditLog.Operation.fromEvent(authzDenialNullIdentifier));
   }
 
+  /**
+   * {@code Operation.fromEvent} dispatches on event class, and the extras 
support added a second
+   * constructor to each table event. Pins that events built through the new 
constructor are still
+   * classified as their operation rather than falling through to {@code 
UNKNOWN}.
+   */
+  @Test
+  public void testCreateTableWithAuditExtrasKeepsCreateTableOperation() {
+    Event success =
+        new CreateTableEvent(
+            USER, tableIdentifier, tableInfo, ImmutableMap.of("audit.reason", 
"policy-applied"));
+    Event failure =
+        new CreateTableFailureEvent(
+            USER,
+            tableIdentifier,
+            new Exception("create failed"),
+            tableInfo,
+            ImmutableMap.of("audit.reason", "validation-failed"));
+
+    Assertions.assertEquals(AuditLog.Operation.CREATE_TABLE, 
AuditLog.Operation.fromEvent(success));
+    Assertions.assertEquals(AuditLog.Operation.CREATE_TABLE, 
AuditLog.Operation.fromEvent(failure));
+  }
+
   @Test
   public void testHttpRequestFailureEventMapsToUnknownOperation() {
     // HttpRequestFailureEvent has no specific business operation — it always 
maps to UNKNOWN
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
new file mode 100644
index 0000000000..e78f864d7c
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.listener;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.event.CreateTableEvent;
+import org.apache.gravitino.listener.api.event.Event;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers the audit-extras path on {@link TableEventDispatcher}: how facts 
stashed on {@link
+ * RequestContext} reach the emitted event, and how listener failures are 
contained once an
+ * operation opts in. Event field mapping itself is covered by {@code 
TestTableEvent}.
+ */
+public class TestTableEventDispatcher {
+
+  private static final NameIdentifier IDENT =
+      NameIdentifier.of("metalake", "catalog", "schema", "table");
+
+  @AfterEach
+  void cleanup() {
+    RequestContext.clear();
+    Thread.interrupted();
+  }
+
+  /**
+   * This change is meant to be purely additive: extras enrich the event, and 
nothing about error
+   * propagation moves. A synchronous listener failure therefore has to reach 
the caller exactly as
+   * it does today, whether or not extras were stashed. Asserting both halves 
in one test is what
+   * makes a future swallow branch visible, since adding one would only break 
the extras case.
+   */
+  @Test
+  void testAttachingExtrasDoesNotChangeListenerFailurePropagation() {
+    EventListenerPlugin listener = mock(EventListenerPlugin.class);
+    doThrow(new RuntimeException("listener 
failed")).when(listener).onPostEvent(any(Event.class));
+
+    RuntimeException withoutExtras =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> createTable(dispatcher(listener, 
mockTable())));
+    Assertions.assertEquals("listener failed", withoutExtras.getMessage());
+
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+    RuntimeException withExtras =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> createTable(dispatcher(listener, 
mockTable())));
+    Assertions.assertEquals("listener failed", withExtras.getMessage());
+  }
+
+  /**
+   * The point of routing extras through the request context is to enrich the 
event the dispatcher
+   * already emits rather than to publish a second one. Pins both halves: the 
fact reaches {@code
+   * customInfo}, and exactly one post event is produced for the operation.
+   */
+  @Test
+  void testCreateWithExtrasDispatchesOneCreateTableEvent() {
+    DummyEventListener listener = new DummyEventListener();
+    TableEventDispatcher dispatcher = dispatcher(listener, mockTable());
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+
+    createTable(dispatcher);
+
+    Event event = listener.popPostEvent();
+    Assertions.assertEquals(CreateTableEvent.class, event.getClass());
+    Assertions.assertEquals("policy-applied", 
event.customInfo().get("audit.reason"));
+    Assertions.assertTrue(listener.getPostEvents().isEmpty());
+  }
+
+  /**
+   * Server threads are pooled, so a fact left behind by one request would be 
mis-attributed to
+   * whichever request reuses the thread next. Pins that the dispatcher 
consumes the stash rather
+   * than reading it, by running a second operation on the same thread and 
requiring clean extras.
+   */
+  @Test
+  void testExtrasDoNotLeakIntoTheNextOperationOnTheSameThread() {
+    DummyEventListener listener = new DummyEventListener();
+    TableEventDispatcher dispatcher = dispatcher(listener, mockTable());
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+
+    createTable(dispatcher);
+    Assertions.assertEquals(
+        "policy-applied", 
listener.popPostEvent().customInfo().get("audit.reason"));
+
+    createTable(dispatcher);
+
+    Event second = listener.popPostEvent();
+    Assertions.assertEquals(CreateTableEvent.class, second.getClass());
+    Assertions.assertTrue(
+        second.customInfo().isEmpty(), "Extras must not survive into a later 
operation");
+  }
+
+  private static TableEventDispatcher dispatcher(EventListenerPlugin listener, 
Table table) {
+    TableDispatcher catalog = mock(TableDispatcher.class);
+    when(catalog.createTable(
+            any(NameIdentifier.class),
+            any(Column[].class),
+            any(String.class),
+            any(Map.class),
+            any(Transform[].class),
+            any(Distribution.class),
+            any(SortOrder[].class),
+            any(Index[].class)))
+        .thenReturn(table);
+    return new TableEventDispatcher(new 
EventBus(Collections.singletonList(listener)), catalog);
+  }
+
+  private static void createTable(TableEventDispatcher dispatcher) {
+    dispatcher.createTable(
+        IDENT,
+        new Column[] {Column.of("id", Types.LongType.get())},
+        "comment",
+        ImmutableMap.of(),
+        new Transform[0],
+        Distributions.NONE,
+        new SortOrder[0],
+        Indexes.EMPTY_INDEXES);
+  }
+
+  private static Table mockTable() {
+    Table table = mock(Table.class);
+    when(table.name()).thenReturn("table");
+    when(table.comment()).thenReturn("comment");
+    when(table.columns()).thenReturn(new Column[] {Column.of("id", 
Types.LongType.get())});
+    when(table.properties()).thenReturn(ImmutableMap.of());
+    when(table.partitioning()).thenReturn(new Transform[0]);
+    when(table.distribution()).thenReturn(Distributions.NONE);
+    when(table.sortOrder()).thenReturn(new SortOrder[0]);
+    when(table.index()).thenReturn(Indexes.EMPTY_INDEXES);
+    when(table.auditInfo()).thenReturn(null);
+    return table;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
index bd07402602..271911ee7b 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
@@ -48,6 +48,8 @@ import 
org.apache.gravitino.rel.expressions.transforms.Transforms;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.indexes.Indexes;
 import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -72,6 +74,11 @@ public class TestTableEvent {
     this.failureDispatcher = new TableEventDispatcher(eventBus, 
tableExceptionDispatcher);
   }
 
+  @AfterEach
+  void clearRequestContext() {
+    RequestContext.clear();
+  }
+
   @Test
   void testCreateTableEvent() {
     NameIdentifier identifier = NameIdentifier.of("metalake", "catalog", 
table.name());
@@ -92,6 +99,7 @@ public class TestTableEvent {
     checkTableInfo(tableInfo, table);
     Assertions.assertEquals(OperationType.CREATE_TABLE, event.operationType());
     Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertTrue(event.customInfo().isEmpty());
 
     PreEvent preEvent = dummyEventListener.popPreEvent();
     Assertions.assertEquals(identifier, preEvent.identifier());
@@ -114,6 +122,7 @@ public class TestTableEvent {
     checkTableInfo(tableInfo, table);
     Assertions.assertEquals(OperationType.LOAD_TABLE, event.operationType());
     Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertTrue(event.customInfo().isEmpty());
 
     PreEvent preEvent = dummyEventListener.popPreEvent();
     Assertions.assertEquals(identifier, preEvent.identifier());
@@ -137,6 +146,7 @@ public class TestTableEvent {
     Assertions.assertEquals(change, ((AlterTableEvent) 
event).tableChanges()[0]);
     Assertions.assertEquals(OperationType.ALTER_TABLE, event.operationType());
     Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+    Assertions.assertTrue(event.customInfo().isEmpty());
 
     PreEvent preEvent = dummyEventListener.popPreEvent();
     Assertions.assertEquals(identifier, preEvent.identifier());
@@ -206,6 +216,81 @@ public class TestTableEvent {
     Assertions.assertEquals(OperationStatus.UNPROCESSED, 
preEvent.operationStatus());
   }
 
+  /**
+   * End-to-end check through the real dispatcher that a stashed fact lands on 
the success event and
+   * that the dispatcher consumed the stash. Pins the success half of the 
contract that {@code
+   * TestTableEventDispatcher} exercises against mocks.
+   */
+  @Test
+  void testCreateTableEventAttachesStashedExtras() {
+    NameIdentifier identifier = NameIdentifier.of("metalake", "catalog", 
table.name());
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+    dispatcher.createTable(
+        identifier,
+        table.columns(),
+        table.comment(),
+        table.properties(),
+        table.partitioning(),
+        table.distribution(),
+        table.sortOrder(),
+        table.index());
+
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CreateTableEvent.class, event.getClass());
+    Assertions.assertEquals("policy-applied", 
event.customInfo().get("audit.reason"));
+    dummyEventListener.popPreEvent();
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+  }
+
+  /**
+   * A contributor that rejected the operation is exactly the case where the 
reason matters most, so
+   * extras have to survive the exception path and reach the failure event. 
The success and failure
+   * paths read the stash in separate branches, so both need pinning.
+   */
+  @Test
+  void testCreateTableFailureEventAttachesStashedExtras() {
+    NameIdentifier identifier = NameIdentifier.of("metalake", "catalog", 
table.name());
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"validation-failed"));
+    Assertions.assertThrowsExactly(
+        GravitinoRuntimeException.class,
+        () ->
+            failureDispatcher.createTable(
+                identifier,
+                table.columns(),
+                table.comment(),
+                table.properties(),
+                table.partitioning(),
+                table.distribution(),
+                table.sortOrder(),
+                table.index()));
+    Event event = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(CreateTableFailureEvent.class, event.getClass());
+    Assertions.assertEquals("validation-failed", 
event.customInfo().get("audit.reason"));
+  }
+
+  /**
+   * Each table operation reads the stash in its own hand-written branch, so 
create passing does not
+   * imply alter and load pass. Covers the two remaining operations and, by 
stashing a second fact
+   * between them, that consecutive operations on one thread get their own 
value.
+   */
+  @Test
+  void testAlterAndLoadEventsAttachStashedExtras() {
+    NameIdentifier identifier = NameIdentifier.of("metalake", "catalog", 
table.name());
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+    dispatcher.alterTable(identifier, TableChange.setProperty("a", "b"));
+    Event alterEvent = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(AlterTableEvent.class, alterEvent.getClass());
+    Assertions.assertEquals("policy-applied", 
alterEvent.customInfo().get("audit.reason"));
+    dummyEventListener.popPreEvent();
+
+    RequestContext.setAuditExtras(ImmutableMap.of("audit.reason", 
"cache-miss"));
+    dispatcher.loadTable(identifier);
+    Event loadEvent = dummyEventListener.popPostEvent();
+    Assertions.assertEquals(LoadTableEvent.class, loadEvent.getClass());
+    Assertions.assertEquals("cache-miss", 
loadEvent.customInfo().get("audit.reason"));
+    dummyEventListener.popPreEvent();
+  }
+
   @Test
   void testCreateTableFailureEvent() {
     NameIdentifier identifier = NameIdentifier.of("metalake", "catalog", 
table.name());
diff --git 
a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java 
b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
index 5c70284446..faf707e168 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
@@ -19,6 +19,8 @@
 
 package org.apache.gravitino.utils;
 
+import java.util.Collections;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
@@ -67,4 +69,75 @@ public class TestRequestContext {
     Assertions.assertEquals(
         "main-thread-ip", RequestContext.getRemoteAddress(), "Main thread 
value unchanged");
   }
+
+  /**
+   * Callers read the stash unconditionally on every table operation, so the 
common case is that
+   * nothing was stashed. Pins that this returns an empty map rather than 
{@code null}, which keeps
+   * the dispatcher free of a null check on the hot path.
+   */
+  @Test
+  public void testTakeAuditExtrasReturnsEmptyWhenUnset() {
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+  }
+
+  /**
+   * The stash is a hand-off between two dispatcher layers, not a 
request-scoped register: exactly
+   * one reader is expected to consume each set. Pins the take-and-clear 
semantics that the
+   * dispatcher relies on to avoid attributing a fact to a later operation.
+   */
+  @Test
+  public void testSetAndTakeAuditExtras() {
+    RequestContext.setAuditExtras(Collections.singletonMap("audit.reason", 
"policy-applied"));
+    Assertions.assertEquals("policy-applied", 
RequestContext.takeAuditExtras().get("audit.reason"));
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty(), "take 
must clear extras");
+  }
+
+  /**
+   * A contributor that decides it has nothing to report will naturally pass 
an empty or {@code
+   * null} map. Pins that this retracts an earlier stash instead of being a 
no-op, so a stale fact
+   * from an earlier decision cannot be attached to the event.
+   */
+  @Test
+  public void testEmptyOrNullAuditExtrasClearsStash() {
+    RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+    RequestContext.setAuditExtras(Collections.emptyMap());
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+
+    RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+    RequestContext.setAuditExtras(null);
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+  }
+
+  /**
+   * Extras are a third {@link ThreadLocal} on a pooled servlet thread, so the 
request-teardown
+   * sweep has to know about them. Pins that {@code clear()} was extended 
alongside the new state
+   * and cannot silently leave it bound to the thread.
+   */
+  @Test
+  public void testClearRemovesAuditExtras() {
+    RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+    RequestContext.clear();
+    Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+  }
+
+  /**
+   * The stash is only safe because a contributor and the dispatcher share one 
servlet thread. Pins
+   * that a fact set on one thread is invisible to another, which is the 
assumption that makes a
+   * {@link ThreadLocal} acceptable here in place of a threaded-through 
parameter.
+   */
+  @Test
+  public void testAuditExtrasAreThreadConfined() throws InterruptedException {
+    RequestContext.setAuditExtras(Collections.singletonMap("audit.reason", 
"policy-applied"));
+    AtomicReference<Map<String, String>> childValue = new AtomicReference<>();
+
+    Thread child = new Thread(() -> 
childValue.set(RequestContext.takeAuditExtras()));
+    child.start();
+    child.join();
+
+    Assertions.assertTrue(childValue.get().isEmpty(), "Child thread must not 
see stashed extras");
+    Assertions.assertEquals(
+        "policy-applied",
+        RequestContext.takeAuditExtras().get("audit.reason"),
+        "Child thread must not consume the parent's stash");
+  }
 }

Reply via email to