android_sdk: encode track_event body in Java; unify tracks behind PerfettoTrack

Encode debug args / proto fields / flows once in Java (ProtoWriter) and
hand them to the HL emit as one verbatim RawBody field, removing the
per-arg/field native objects and the per-event allocation.

Build on the nested-tracks PerfettoTrack handle, extending it to counter,
thread-tid and static-name leaves so named / nested / counter tracks are
one immutable handle that owns its native track (built once, lock-free,
freed on GC). The flat usingProcessNamedTrack / usingCounterTrack helpers
are sugar over it, keeping a small content cache since they build a fresh
handle per emit.
diff --git a/include/perfetto/public/abi/track_event_hl_abi.h b/include/perfetto/public/abi/track_event_hl_abi.h
index 2ae69d6..692d2ce 100644
--- a/include/perfetto/public/abi/track_event_hl_abi.h
+++ b/include/perfetto/public/abi/track_event_hl_abi.h
@@ -47,6 +47,10 @@
   PERFETTO_TE_HL_PROTO_TYPE_DOUBLE = 6,
   PERFETTO_TE_HL_PROTO_TYPE_FLOAT = 7,
   PERFETTO_TE_HL_PROTO_TYPE_CSTR_INTERNED = 8,
+  // Pre-serialized protobuf appended verbatim into the enclosing message (no
+  // field-id wrapper). Lets a caller batch-encode several fields on its own side
+  // and hand them over as one blob. `header.id` is ignored.
+  PERFETTO_TE_HL_PROTO_TYPE_RAW = 9,
 };
 
 // Common header for all the proto fields.
@@ -81,6 +85,14 @@
   size_t len;
 };
 
+// PERFETTO_TE_HL_PROTO_TYPE_RAW
+struct PerfettoTeHlProtoFieldRaw {
+  struct PerfettoTeHlProtoField header;
+  // Pre-serialized protobuf, appended verbatim (no field-id/length wrapper).
+  const void* buf;
+  size_t len;
+};
+
 // PERFETTO_TE_HL_PROTO_TYPE_NESTED
 struct PerfettoTeHlProtoFieldNested {
   struct PerfettoTeHlProtoField header;
@@ -122,6 +134,7 @@
 union PerfettoTeHlProtoFieldUnion {
   struct PerfettoTeHlProtoFieldCstr field_cstr;
   struct PerfettoTeHlProtoFieldBytes field_bytes;
+  struct PerfettoTeHlProtoFieldRaw field_raw;
   struct PerfettoTeHlProtoFieldNested field_nested;
   struct PerfettoTeHlProtoFieldVarInt field_varint;
   struct PerfettoTeHlProtoFieldFixed64 field_fixed64;
diff --git a/src/android_sdk/java/host_stubs/android/os/Bundle.java b/src/android_sdk/java/host_stubs/android/os/Bundle.java
new file mode 100644
index 0000000..2cbf1c4
--- /dev/null
+++ b/src/android_sdk/java/host_stubs/android/os/Bundle.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed 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 android.os;
+
+/** Host-JVM stub of android.os.Bundle (just enough for instrumentation args). */
+public class Bundle {
+  public String getString(String key) {
+    return null;
+  }
+}
diff --git a/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java b/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java
new file mode 100644
index 0000000..939d7d0
--- /dev/null
+++ b/src/android_sdk/java/host_stubs/androidx/test/platform/app/InstrumentationRegistry.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed 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 androidx.test.platform.app;
+
+import android.os.Bundle;
+
+/** Host-JVM stub of androidx.test's InstrumentationRegistry (no args on host). */
+public final class InstrumentationRegistry {
+  private InstrumentationRegistry() {}
+
+  public static Bundle getArguments() {
+    return new Bundle();
+  }
+}
diff --git a/src/android_sdk/java/main/BUILD.gn b/src/android_sdk/java/main/BUILD.gn
index be304c2..39e32d4 100644
--- a/src/android_sdk/java/main/BUILD.gn
+++ b/src/android_sdk/java/main/BUILD.gn
@@ -5,11 +5,13 @@
 
 perfetto_android_library("perfetto_trace_lib") {
   sources = [
+    "dev/perfetto/sdk/PerfettoTrackEventEncoder.java",
     "dev/perfetto/sdk/PerfettoNativeMemoryCleaner.java",
     "dev/perfetto/sdk/PerfettoTrace.java",
     "dev/perfetto/sdk/PerfettoTrack.java",
     "dev/perfetto/sdk/PerfettoTrackEventBuilder.java",
     "dev/perfetto/sdk/PerfettoTrackEventExtra.java",
+    "dev/perfetto/sdk/ProtoWriter.java",
   ]
   deps = [
     "../../../../gn:error_prone_annotations",
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java
index 6d4b510..6c677ba 100644
--- a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java
+++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventBuilder.java
@@ -19,20 +19,14 @@
 import com.google.errorprone.annotations.CompileTimeConstant;
 
 import java.util.ArrayList;
-import java.util.function.Supplier;
 
 import dev.perfetto.sdk.PerfettoNativeMemoryCleaner.AllocationStats;
 import dev.perfetto.sdk.PerfettoTrace.Category;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.Arg;
 import dev.perfetto.sdk.PerfettoTrackEventExtra.Counter;
 import dev.perfetto.sdk.PerfettoTrackEventExtra.CounterTrack;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.Field;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.FieldContainer;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.FieldNested;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.Flow;
 import dev.perfetto.sdk.PerfettoTrackEventExtra.NamedTrack;
+import dev.perfetto.sdk.PerfettoTrackEventExtra.NestedTracks;
 import dev.perfetto.sdk.PerfettoTrackEventExtra.PerfettoPointer;
-import dev.perfetto.sdk.PerfettoTrackEventExtra.Proto;
 
 /** Builder for Perfetto track event extras. */
 public final class PerfettoTrackEventBuilder {
@@ -47,89 +41,61 @@
   private boolean mIsBuilt = false;
   private boolean mIsDebug = false;
 
-  private PerfettoTrackEventBuilder mParent;
-  private FieldContainer mCurrentContainer;
-
   private PerfettoNativeMemoryCleaner mNativeMemoryCleaner;
   private static final PerfettoNativeMemoryCleaner.AllocationStats sNativeAllocationStats =
       new AllocationStats();
 
-  private static final class ObjectsPool {
-    public final Pool<Field> mFieldPool;
-    public final Pool<FieldNested> mFieldNestedPool;
-    public final Pool<Proto> mProtoPool;
-    public final Pool<Flow> mFlowPool;
-    public final Pool<Flow> mTerminatingFlowPool;
-
-    public ObjectsPool(int capacity) {
-      mFieldPool = new Pool<>(capacity);
-      mFieldNestedPool = new Pool<>(capacity);
-      mProtoPool = new Pool<>(capacity);
-      mFlowPool = new Pool<>(capacity);
-      mTerminatingFlowPool = new Pool<>(capacity);
-    }
-
-    public void reset() {
-      mFieldPool.reset();
-      mFieldNestedPool.reset();
-      mProtoPool.reset();
-      mFlowPool.reset();
-      mTerminatingFlowPool.reset();
-    }
-  }
-
-  private static final class ObjectsCache {
-    public final RingBuffer<NamedTrack> mNamedTrackCache;
-    public final RingBuffer<CounterTrack> mCounterTrackCache;
-    public final RingBuffer<Arg> mArgCache;
-
-    public ObjectsCache(int capacity) {
-      mNamedTrackCache = new RingBuffer<>(capacity);
-      mCounterTrackCache = new RingBuffer<>(capacity);
-      mArgCache = new RingBuffer<>(capacity);
-    }
-  }
-
-  private static final class LazyInitObjects {
-    private Counter mCounter = null;
-
-    private final PerfettoNativeMemoryCleaner mNativeMemoryCleaner;
-
-    private LazyInitObjects(PerfettoNativeMemoryCleaner memoryCleaner) {
-      this.mNativeMemoryCleaner = memoryCleaner;
-    }
-
-    public Counter getCounter() {
-      if (mCounter == null) {
-        mCounter = new Counter(mNativeMemoryCleaner);
-      }
-      return mCounter;
-    }
-  }
-
-  private Pool<PerfettoTrackEventBuilder> mChildBuildersCache;
-  private ObjectsPool mObjectsPool;
+  // Content cache for the flat track sugar (usingProcessNamedTrack /
+  // usingCounterTrack etc.), which builds a fresh handle per emit. Keyed by
+  // PerfettoTrack.flatCacheKey (folds in the counter flag, so a counter and a
+  // same-named track never collide). The usingTrack(PerfettoTrack) path needs no
+  // cache -- the handle owns its native track.
+  private RingBuffer<NestedTracks> mTrackCache;
   private ObjectsCache mObjectsCache;
-  private LazyInitObjects mLazyInitObjects;
 
   private final boolean mIsCategoryEnabled;
 
   private ArrayList<PerfettoPointer> mPendingPointers;
 
-  private final Supplier<PerfettoTrackEventBuilder> perfettoTrackEventBuilderSupplier =
-      () -> new PerfettoTrackEventBuilder(true, this);
-  private final Supplier<FieldNested> fieldNestedSupplier =
-      () -> new FieldNested(mNativeMemoryCleaner);
-  private final Supplier<Proto> protoSupplier = () -> new Proto(mNativeMemoryCleaner);
-  private final Supplier<Field> fieldSupplier = () -> new Field(mNativeMemoryCleaner);
-  private final Supplier<Flow> flowSupplier = () -> new Flow(mNativeMemoryCleaner);
+  // Tracks whether the builder is between a beginProto() and its endProto(), so
+  // the debug-only state checks can validate that field operations are scoped to
+  // a proto block. Plain proto fields are written straight into the body; the
+  // depth count gates endNested() back-fills.
+  private boolean mInProto;
+  private int mProtoDepth;
+
+  // Whether this event carries any interned proto field. Interned strings can't
+  // be Java-encoded -- their iids are assigned per-sequence by the native
+  // interning tables -- so addFieldWithInterning() hands them to the RawBody
+  // extra, which carries them as CSTR_INTERNED fields alongside the body. This
+  // flag makes emit() register RawBody even when the body itself is empty.
+  private boolean mHasInterned;
+
+  // The TrackEvent body: debug annotations, flows, counter value and plain proto
+  // fields are encoded here, then handed to native as one raw proto field. Owned
+  // by the root builder (already thread-local), so the hot path does no per-call
+  // ThreadLocal lookup. Reset at the start of each event.
+  private ProtoWriter mBody;
+
+  // Reused extra that copies the encoded body into its own native buffer and
+  // carries it to native as one raw proto field.
+  private PerfettoTrackEventExtra.RawBody mRawBody;
+
+  // Single reused native counter extra, created on first use. A counter value
+  // has no identity to cache, so one object per (thread-local) builder suffices
+  // -- no pool or ring buffer. It stays native (not body-encoded) because a
+  // CriticalNative value-set is cheaper than routing one tiny field through the
+  // body. Flows differ: an event may carry several, so re-adding them would need
+  // a Flow pool, and they stay body-encoded instead.
+  private Counter mCounter;
+
 
   private static final PerfettoTrackEventBuilder NO_OP_BUILDER =
-      new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ false, /* parent= */ null);
+      new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ false);
 
   public static final ThreadLocal<PerfettoTrackEventBuilder> sThreadLocalBuilder =
       ThreadLocal.withInitial(
-          () -> new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ true, /* parent= */ null));
+          () -> new PerfettoTrackEventBuilder(/* isCategoryEnabled= */ true));
 
   public static PerfettoTrackEventBuilder newEvent(
       int traceType, Category category, boolean isDebug) {
@@ -139,28 +105,18 @@
     return NO_OP_BUILDER;
   }
 
-  private PerfettoTrackEventBuilder(boolean isCategoryEnabled, PerfettoTrackEventBuilder parent) {
+  private PerfettoTrackEventBuilder(boolean isCategoryEnabled) {
     mIsCategoryEnabled = isCategoryEnabled;
     if (!mIsCategoryEnabled) {
       // No fields of this builder will be used, no need to initialize them.
       return;
     }
-    if (parent == null) {
-      // We are creating a root builder which will be saved in thread local storage.
-      mParent = null;
-      mNativeMemoryCleaner = new PerfettoNativeMemoryCleaner(null);
-      mChildBuildersCache = new Pool<>(DEFAULT_EXTRA_CACHE_SIZE);
-      mObjectsPool = new ObjectsPool(DEFAULT_EXTRA_CACHE_SIZE);
-      mObjectsCache = new ObjectsCache(DEFAULT_EXTRA_CACHE_SIZE);
-      mLazyInitObjects = new LazyInitObjects(mNativeMemoryCleaner);
-      mPendingPointers = new ArrayList<>(DEFAULT_PENDING_POINTERS_LIST_SIZE);
-    } else {
-      // We are create a child builder for proto fields, read all cache fields from the parent.
-      mParent = parent;
-      mNativeMemoryCleaner = parent.mNativeMemoryCleaner;
-      readAllCacheFieldsFromParent(parent);
-    }
-
+    mNativeMemoryCleaner = new PerfettoNativeMemoryCleaner(null);
+    mTrackCache = new RingBuffer<>(DEFAULT_EXTRA_CACHE_SIZE);
+    mObjectsCache = new ObjectsCache(DEFAULT_EXTRA_CACHE_SIZE);
+    mPendingPointers = new ArrayList<>(DEFAULT_PENDING_POINTERS_LIST_SIZE);
+    mBody = new ProtoWriter();
+    mRawBody = new PerfettoTrackEventExtra.RawBody(mNativeMemoryCleaner);
     mExtra = new PerfettoTrackEventExtra(mNativeMemoryCleaner);
   }
 
@@ -174,8 +130,20 @@
     }
 
     mIsBuilt = true;
+    int bodyLen = mBody.position();
+    if (bodyLen > 0 || mHasInterned) {
+      // mRawBody is a permanent field of this (thread-local) builder, so it can
+      // never be GC'd mid-emit and does not need mPendingPointers tracking
+      // (which exists only to keep transiently-referenced extras alive across
+      // the native call). Register it on the extra directly. It also carries any
+      // interned fields, so it must ride even when the body is empty.
+      mExtra.addPerfettoPointer(mRawBody);
+    }
+    // One JNI crossing: native_emit copies the heap-encoded body into the
+    // RawBody's native buffer (when bodyLen > 0) and emits in the same call.
     PerfettoTrackEventExtra.native_emit(
-        mTraceType, mCategory.getPtr(), mEventName, mExtra.getPtr());
+        mTraceType, mCategory.getPtr(), mEventName, mExtra.getPtr(),
+        mRawBody.bodyPtr(), mBody.buffer(), bodyLen);
   }
 
   /** Initialize the builder for a new trace event. */
@@ -185,7 +153,6 @@
       return this;
     }
     mIsBuilt = false;
-    mParent = null;
     mIsDebug = isDebug;
     updateNativeMemoryCleanerForDebug(mIsDebug);
     mTraceType = traceType;
@@ -193,27 +160,15 @@
     mEventName = "";
 
     mExtra.reset();
-    mChildBuildersCache.reset();
-    mObjectsPool.reset();
     mPendingPointers.clear();
-    mCurrentContainer = null;
+    mInProto = false;
+    mProtoDepth = 0;
+    mHasInterned = false;
+    mBody.reset();
 
     return this;
   }
 
-  private PerfettoTrackEventBuilder initChildBuilderForProto(
-      PerfettoTrackEventBuilder parent, FieldContainer fieldContainer) {
-    mIsBuilt = false;
-    mParent = parent;
-    mIsDebug = parent.mIsDebug;
-    updateNativeMemoryCleanerForDebug(mIsDebug);
-
-    readAllCacheFieldsFromParent(parent);
-
-    mCurrentContainer = fieldContainer;
-    return this;
-  }
-
   private void updateNativeMemoryCleanerForDebug(boolean enableDebug) {
     // In current implementation it is possible, that the 'PerfettoTrackEventBuilder' will be
     // used
@@ -234,14 +189,6 @@
     }
   }
 
-  private void readAllCacheFieldsFromParent(PerfettoTrackEventBuilder parent) {
-    mChildBuildersCache = parent.mChildBuildersCache;
-    mObjectsPool = parent.mObjectsPool;
-    mObjectsCache = parent.mObjectsCache;
-    mLazyInitObjects = parent.mLazyInitObjects;
-    mPendingPointers = parent.mPendingPointers;
-  }
-
   /** Sets the event name for the track event. */
   public PerfettoTrackEventBuilder setEventName(String eventName) {
     mEventName = eventName;
@@ -256,13 +203,7 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Arg arg = mObjectsCache.mArgCache.get(name.hashCode());
-    if (arg == null || !arg.getName().equals(name)) {
-      arg = new Arg(name, mNativeMemoryCleaner);
-      mObjectsCache.mArgCache.put(name.hashCode(), arg);
-    }
-    arg.setValueInt64(val);
-    addPerfettoPointerToExtra(arg);
+    PerfettoTrackEventEncoder.addArg(mBody, name, val);
     return this;
   }
 
@@ -274,13 +215,7 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Arg arg = mObjectsCache.mArgCache.get(name.hashCode());
-    if (arg == null || !arg.getName().equals(name)) {
-      arg = new Arg(name, mNativeMemoryCleaner);
-      mObjectsCache.mArgCache.put(name.hashCode(), arg);
-    }
-    arg.setValueBool(val);
-    addPerfettoPointerToExtra(arg);
+    PerfettoTrackEventEncoder.addArg(mBody, name, val);
     return this;
   }
 
@@ -292,13 +227,7 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Arg arg = mObjectsCache.mArgCache.get(name.hashCode());
-    if (arg == null || !arg.getName().equals(name)) {
-      arg = new Arg(name, mNativeMemoryCleaner);
-      mObjectsCache.mArgCache.put(name.hashCode(), arg);
-    }
-    arg.setValueDouble(val);
-    addPerfettoPointerToExtra(arg);
+    PerfettoTrackEventEncoder.addArg(mBody, name, val);
     return this;
   }
 
@@ -310,13 +239,7 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Arg arg = mObjectsCache.mArgCache.get(name.hashCode());
-    if (arg == null || !arg.getName().equals(name)) {
-      arg = new Arg(name, mNativeMemoryCleaner);
-      mObjectsCache.mArgCache.put(name.hashCode(), arg);
-    }
-    arg.setValueString(val);
-    addPerfettoPointerToExtra(arg);
+    PerfettoTrackEventEncoder.addArg(mBody, name, val);
     return this;
   }
 
@@ -333,9 +256,7 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Flow flow = mObjectsPool.mFlowPool.get(flowSupplier);
-    flow.setProcessFlow(id);
-    addPerfettoPointerToExtra(flow);
+    PerfettoTrackEventEncoder.addFlow(mBody, id);
     return this;
   }
 
@@ -352,18 +273,20 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Flow terminatingFlow = mObjectsPool.mTerminatingFlowPool.get(flowSupplier);
-    terminatingFlow.setProcessTerminatingFlow(id);
-    addPerfettoPointerToExtra(terminatingFlow);
+    PerfettoTrackEventEncoder.addTerminatingFlow(mBody, id);
     return this;
   }
 
   /**
    * Emits this event on {@code track}, a (possibly nested) named track. The
-   * descriptor for each level of the chain is emitted once per sequence; the
-   * native side derives the per-level uuids. The handle owns its native track
-   * (built once on first use, reused for its lifetime), so a reused {@code track}
-   * -- the intended {@code static final} usage -- is allocation-free.
+   * descriptor for each level of the chain is emitted once per sequence. The
+   * native side derives the per-level uuids; nothing is hardcoded here. The
+   * {@link NestedTracks} wrapper is cached per {@link PerfettoTrack}, so this is
+   * allocation-free after the first use of a given track.
+   *
+   * <p>This is the single named/nested track entry point. The flat {@code
+   * usingProcessNamedTrack} / {@code usingThreadNamedTrack} helpers below are
+   * thin sugar over it.
    */
   public PerfettoTrackEventBuilder usingTrack(PerfettoTrack track) {
     if (!mIsCategoryEnabled) {
@@ -544,16 +467,19 @@
   }
 
   /** Sets a long counter value on the event. */
-  public PerfettoTrackEventBuilder setCounter(long val) {
+
+    public PerfettoTrackEventBuilder setCounter(long val) {
     if (!mIsCategoryEnabled) {
       return this;
     }
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Counter counter = mLazyInitObjects.getCounter();
-    counter.setValueInt64(val);
-    addPerfettoPointerToExtra(counter);
+    if (mCounter == null) {
+      mCounter = new Counter(mNativeMemoryCleaner);
+    }
+    mCounter.setValueInt64(val);
+    addPerfettoPointerToExtra(mCounter);
     return this;
   }
 
@@ -565,9 +491,11 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Counter counter = mLazyInitObjects.getCounter();
-    counter.setValueDouble(val);
-    addPerfettoPointerToExtra(counter);
+    if (mCounter == null) {
+      mCounter = new Counter(mNativeMemoryCleaner);
+    }
+    mCounter.setValueDouble(val);
+    addPerfettoPointerToExtra(mCounter);
     return this;
   }
 
@@ -579,9 +507,7 @@
     if (mIsDebug) {
       checkBuildingProto();
     }
-    Field field = mObjectsPool.mFieldPool.get(fieldSupplier);
-    field.setValueInt64(id, val);
-    addFieldToContainer(field);
+    PerfettoTrackEventEncoder.protoVarInt(mBody, (int) id, val);
     return this;
   }
 
@@ -593,9 +519,7 @@
     if (mIsDebug) {
       checkBuildingProto();
     }
-    Field field = mObjectsPool.mFieldPool.get(fieldSupplier);
-    field.setValueDouble(id, val);
-    addFieldToContainer(field);
+    PerfettoTrackEventEncoder.protoDouble(mBody, (int) id, val);
     return this;
   }
 
@@ -607,16 +531,16 @@
     if (mIsDebug) {
       checkBuildingProto();
     }
-    Field field = mObjectsPool.mFieldPool.get(fieldSupplier);
-    field.setValueString(id, val);
-    addFieldToContainer(field);
+    PerfettoTrackEventEncoder.protoString(mBody, (int) id, val);
     return this;
   }
 
   /**
-   * Adds a proto field with field id { @code id} and value { @code val}.
-   * { @code internedTypeId} must be non-zero, in which case the string { @code val} will be interned
-   * with the given type ID. If { @code internedTypeId} is zero, the string is dropped silently.
+   * Adds a proto field with field id {@code id} whose string {@code val} is
+   * interned under {@code internedTypeId} (an InternedData field number). The
+   * string is interned natively (its iid is assigned per-sequence), riding on the
+   * event's raw-body extra alongside the encoded body. {@code internedTypeId}
+   * must be non-zero.
    */
   public PerfettoTrackEventBuilder addFieldWithInterning(long id, String val, long internedTypeId) {
     if (!mIsCategoryEnabled) {
@@ -625,9 +549,11 @@
     if (mIsDebug) {
       checkBuildingProto();
     }
-    Field field = mObjectsPool.mFieldPool.get(fieldSupplier);
-    field.setValueWithInterning(id, val, internedTypeId);
-    addFieldToContainer(field);
+    if (internedTypeId == 0) {
+      return this;
+    }
+    mRawBody.addInterned(id, val, internedTypeId);
+    mHasInterned = true;
     return this;
   }
 
@@ -645,12 +571,8 @@
     if (mIsDebug) {
       checkNotBuildingProto();
     }
-    Proto proto = mObjectsPool.mProtoPool.get(protoSupplier);
-    proto.clearFields();
-    addPerfettoPointerToExtra(proto);
-    return mChildBuildersCache
-        .get(perfettoTrackEventBuilderSupplier)
-        .initChildBuilderForProto(this, proto);
+    mInProto = true;
+    return this;
   }
 
   /** Ends a proto field. */
@@ -661,7 +583,8 @@
     if (mIsDebug) {
       checkMatchingBeginProto();
     }
-    return mParent;
+    mInProto = false;
+    return this;
   }
 
   /**
@@ -675,12 +598,9 @@
     if (mIsDebug) {
       checkBuildingProto();
     }
-    FieldNested field = mObjectsPool.mFieldNestedPool.get(fieldNestedSupplier);
-    field.setId(id);
-    addFieldToContainer(field);
-    return mChildBuildersCache
-        .get(perfettoTrackEventBuilderSupplier)
-        .initChildBuilderForProto(this, field);
+    PerfettoTrackEventEncoder.protoBeginNested(mBody, (int) id);
+    mProtoDepth++;
+    return this;
   }
 
   /** Ends a nested proto field. */
@@ -688,24 +608,17 @@
     if (!mIsCategoryEnabled) {
       return this;
     }
-
     if (mIsDebug) {
       checkMatchingBeginNested();
     }
-
-    return mParent;
-  }
-
-  private void addFieldToContainer(PerfettoPointer field) {
-    // Keep reference to the java object, `mCurrentContainer` uses a native part of the field
-    // object.
-    mPendingPointers.add(field);
-    mCurrentContainer.addField(field);
+    mProtoDepth--;
+    PerfettoTrackEventEncoder.protoEndNested(mBody);
+    return this;
   }
 
   private void addPerfettoPointerToExtra(PerfettoPointer arg) {
-    // Keep reference to the java object, `mCurrentContainer` uses a native part of the field
-    // object.
+    // Keep a reference to the Java object; the extra holds a native pointer into
+    // the object.
     mPendingPointers.add(arg);
     mExtra.addPerfettoPointer(arg);
   }
@@ -748,31 +661,6 @@
     }
   }
 
-  private static final class Pool<T> {
-    private final int mCapacity;
-    private final T[] mValueArray;
-    private int mIdx = 0;
-
-    Pool(int capacity) {
-      mCapacity = capacity;
-      mValueArray = (T[]) new Object[capacity];
-    }
-
-    public void reset() {
-      mIdx = 0;
-    }
-
-    public T get(Supplier<T> supplier) {
-      if (mIdx >= mCapacity) {
-        return supplier.get();
-      }
-      if (mValueArray[mIdx] == null) {
-        mValueArray[mIdx] = supplier.get();
-      }
-      return mValueArray[mIdx++];
-    }
-  }
-
   private void checkState() {
     if (mIsBuilt) throwStateError();
   }
@@ -783,26 +671,9 @@
         "This builder has already been used. Create a new builder for another event.");
   }
 
-  private boolean isBuildingTopLevelExtra() {
-    return mParent == null && mCurrentContainer == null;
-  }
-
-  private boolean isBuildingProto() {
-    return (mParent != null && mParent.isBuildingTopLevelExtra()) && mCurrentContainer != null;
-  }
-
-  private boolean isBuildingNestedProto() {
-    return (mParent != null && (mParent.isBuildingProtoOrNestedProto()))
-        && mCurrentContainer != null;
-  }
-
-  private boolean isBuildingProtoOrNestedProto() {
-    return isBuildingProto() || isBuildingNestedProto();
-  }
-
   private void checkNotBuildingProto() {
     checkState();
-    if (isBuildingProtoOrNestedProto()) throwNotBuildingProtoError();
+    if (mInProto) throwNotBuildingProtoError();
   }
 
   /** Outlined to keep the caller method small and more likely to be inlined. */
@@ -812,7 +683,7 @@
 
   private void checkBuildingProto() {
     checkState();
-    if (isBuildingTopLevelExtra()) throwBuildingProtoError();
+    if (!mInProto) throwBuildingProtoError();
   }
 
   /** Outlined to keep the caller method small and more likely to be inlined. */
@@ -822,7 +693,7 @@
 
   private void checkMatchingBeginNested() {
     checkState();
-    if (!isBuildingNestedProto()) throwMatchingBeginNestedError();
+    if (mProtoDepth == 0) throwMatchingBeginNestedError();
   }
 
   /** Outlined to keep the caller method small and more likely to be inlined. */
@@ -832,11 +703,21 @@
 
   private void checkMatchingBeginProto() {
     checkState();
-    if (!isBuildingProto()) throwMatchingBeginProtoError();
+    if (!mInProto || mProtoDepth > 0) throwMatchingBeginProtoError();
   }
 
   /** Outlined to keep the caller method small and more likely to be inlined. */
   private static void throwMatchingBeginProtoError() {
     throw new IllegalStateException("No matching beginProto call.");
   }
+
+  private static final class ObjectsCache {
+    public final RingBuffer<NamedTrack> mNamedTrackCache;
+    public final RingBuffer<CounterTrack> mCounterTrackCache;
+
+    public ObjectsCache(int capacity) {
+      mNamedTrackCache = new RingBuffer<>(capacity);
+      mCounterTrackCache = new RingBuffer<>(capacity);
+    }
+  }
 }
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java
new file mode 100644
index 0000000..1a6acfc
--- /dev/null
+++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventEncoder.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed 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 dev.perfetto.sdk;
+
+/**
+ * Encoders for the variable part of a {@code TrackEvent} -- its "body": debug
+ * annotations, flows, the counter value and proto fields.
+ *
+ * <p>Each method writes the corresponding protobuf field(s) into the caller's
+ * reused {@link ProtoWriter}. The encoded body is later handed to native as one
+ * verbatim raw proto field, spliced into the {@code track_event} submessage.
+ * Encoding the body in Java keeps these fields off the per-field native crossing
+ * the High Level extras would otherwise need.
+ *
+ * @hide
+ */
+public final class PerfettoTrackEventEncoder {
+  // TrackEvent field numbers.
+  private static final int TE_DEBUG_ANNOTATIONS = 4;
+  private static final int TE_FLOW_IDS = 47;
+  private static final int TE_TERMINATING_FLOW_IDS = 48;
+
+  // DebugAnnotation field numbers.
+  private static final int DA_BOOL_VALUE = 2;
+  private static final int DA_INT_VALUE = 4;
+  private static final int DA_DOUBLE_VALUE = 5;
+  private static final int DA_STRING_VALUE = 6;
+  private static final int DA_NAME = 10;
+
+  // Process track uuid, cached on first use; flows are xor-folded with it,
+  // matching PerfettoTeProcessScopedFlow in the C SDK.
+  private static volatile long sProcessTrackUuid;
+  private static volatile boolean sProcessTrackUuidValid;
+
+  private PerfettoTrackEventEncoder() {}
+
+  private static long processTrackUuid() {
+    if (!sProcessTrackUuidValid) {
+      sProcessTrackUuid = PerfettoTrace.getProcessTrackUuid();
+      sProcessTrackUuidValid = true;
+    }
+    return sProcessTrackUuid;
+  }
+
+  // All encode methods take the caller's ProtoWriter `b` (owned by the thread-
+  // local PerfettoTrackEventBuilder) so the hot path does no ThreadLocal lookup.
+
+  /** Appends an int64 debug annotation to the body. */
+  static void addArg(ProtoWriter b, String name, long value) {
+    b.beginNested(TE_DEBUG_ANNOTATIONS);
+    b.writeString(DA_NAME, name);
+    b.writeVarInt(DA_INT_VALUE, value);
+    b.endNested();
+  }
+
+  /** Appends a bool debug annotation to the body. */
+  static void addArg(ProtoWriter b, String name, boolean value) {
+    b.beginNested(TE_DEBUG_ANNOTATIONS);
+    b.writeString(DA_NAME, name);
+    b.writeBool(DA_BOOL_VALUE, value);
+    b.endNested();
+  }
+
+  /** Appends a double debug annotation to the body. */
+  static void addArg(ProtoWriter b, String name, double value) {
+    b.beginNested(TE_DEBUG_ANNOTATIONS);
+    b.writeString(DA_NAME, name);
+    b.writeDouble(DA_DOUBLE_VALUE, value);
+    b.endNested();
+  }
+
+  /** Appends a string debug annotation to the body. */
+  static void addArg(ProtoWriter b, String name, String value) {
+    b.beginNested(TE_DEBUG_ANNOTATIONS);
+    b.writeString(DA_NAME, name);
+    b.writeString(DA_STRING_VALUE, value);
+    b.endNested();
+  }
+
+  /** Appends a (process-scoped) flow id to the body. */
+  static void addFlow(ProtoWriter b, long id) {
+    b.writeFixed64(TE_FLOW_IDS, id ^ processTrackUuid());
+  }
+
+  /** Appends a (process-scoped) terminating flow id to the body. */
+  static void addTerminatingFlow(ProtoWriter b, long id) {
+    b.writeFixed64(TE_TERMINATING_FLOW_IDS, id ^ processTrackUuid());
+  }
+
+  /** Appends a varint proto field to the body (for beginProto/addField). */
+  static void protoVarInt(ProtoWriter b, int fieldId, long value) {
+    b.writeVarInt(fieldId, value);
+  }
+
+  /** Appends a double proto field to the body. */
+  static void protoDouble(ProtoWriter b, int fieldId, double value) {
+    b.writeDouble(fieldId, value);
+  }
+
+  /** Appends a string proto field to the body. */
+  static void protoString(ProtoWriter b, int fieldId, String value) {
+    b.writeString(fieldId, value);
+  }
+
+  /** Begins a nested proto message in the body. */
+  static void protoBeginNested(ProtoWriter b, int fieldId) {
+    b.beginNested(fieldId);
+  }
+
+  /** Ends the innermost nested proto message started with {@link #protoBeginNested}. */
+  static void protoEndNested(ProtoWriter b) {
+    b.endNested();
+  }
+}
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java
index c9faaca..42852da 100644
--- a/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java
+++ b/src/android_sdk/java/main/dev/perfetto/sdk/PerfettoTrackEventExtra.java
@@ -61,7 +61,8 @@
   private static native void native_clear_args(long ptr);
 
   @FastNative
-  public static native void native_emit(int type, long tag, String name, long ptr);
+  public static native void native_emit(
+      int type, long tag, String name, long ptr, long rawBodyPtr, byte[] body, int bodyLen);
 
   /** Represents a native pointer to a Perfetto C SDK struct. E.g. PerfettoTeHlExtra. */
   interface PerfettoPointer {
@@ -474,4 +475,51 @@
     @CriticalNative
     private static native void native_set_id(long ptr, long id);
   }
+
+  static final class RawBody implements PerfettoPointer {
+    private final long mPtr;
+    private final long mExtraPtr;
+
+    RawBody(PerfettoNativeMemoryCleaner memoryCleaner) {
+      mPtr = native_init();
+      mExtraPtr = native_get_extra_ptr(mPtr);
+      memoryCleaner.registerNativeAllocation(this, mPtr, native_delete());
+    }
+
+    @Override
+    public long getPtr() {
+      return mExtraPtr;
+    }
+
+    /**
+     * Native RawBody pointer. The body bytes are copied into this object's
+     * buffer inside the {@code native_emit} call (one JNI crossing for copy and
+     * emit together), so there is no separate set-body crossing.
+     */
+    long bodyPtr() {
+      return mPtr;
+    }
+
+    /**
+     * Adds an interned string proto field that rides alongside the body and is
+     * interned natively at emit time (its iid is per-sequence native state the
+     * verbatim body can't carry).
+     */
+    void addInterned(long id, String val, long internedTypeId) {
+      native_add_interned(mPtr, id, val, internedTypeId);
+    }
+
+    @CriticalNative
+    private static native long native_init();
+
+    @FastNative
+    private static native void native_add_interned(
+        long ptr, long id, String val, long internedTypeId);
+
+    @CriticalNative
+    private static native long native_delete();
+
+    @CriticalNative
+    private static native long native_get_extra_ptr(long ptr);
+  }
 }
diff --git a/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java b/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java
new file mode 100644
index 0000000..88e4e44
--- /dev/null
+++ b/src/android_sdk/java/main/dev/perfetto/sdk/ProtoWriter.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed 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 dev.perfetto.sdk;
+
+/**
+ * Zero-allocation protobuf encoder. Pure-Java equivalent of C protozero.
+ *
+ * <p>Writes protobuf wire format directly into a pre-allocated byte buffer.
+ * Designed for encoding Perfetto trace packets on the frame-rendering hot path,
+ * where allocation and GC pressure are unacceptable.
+ *
+ * <p>Each public {@code write*} call does exactly one {@link #ensureCapacity}
+ * (sized for tag + worst-case payload) and then writes the tag and payload via
+ * {@code *NoCheck} helpers that hoist {@link #mBuf}/{@link #mPos} into locals.
+ * This keeps the per-field cost to a single bounds/grow check and one field
+ * write-back, which matters on runtimes that don't inline aggressively.
+ *
+ * <p>Nested messages use a 4-byte redundant varint for the length prefix,
+ * matching protozero's single-pass strategy: the length is reserved before the
+ * submessage body is written and back-filled on {@link #endNested}, so sizes
+ * never need to be pre-computed. The redundant form encodes small values in a
+ * fixed 4 bytes (e.g. 5 becomes {@code 0x85 0x80 0x80 0x00}); this is valid
+ * protobuf that every conformant decoder accepts.
+ *
+ * <p>Thread safety: not thread-safe. Each thread must use its own instance
+ * (typically held in a {@link ThreadLocal}).
+ *
+ * @hide
+ */
+public final class ProtoWriter {
+  private static final int WIRE_TYPE_VARINT = 0;
+  private static final int WIRE_TYPE_FIXED64 = 1;
+  private static final int WIRE_TYPE_DELIMITED = 2;
+
+  // Worst-case encoded sizes, used to size a single ensureCapacity per field.
+  private static final int MAX_VARINT_LEN = 10; // a 64-bit varint
+  private static final int MAX_TAG_LEN = 5; // a field tag varint (field nums to 2^28)
+
+  // Matches PROTOZERO_MESSAGE_LENGTH_FIELD_SIZE in pb_msg.h. A nested message
+  // can therefore be up to 0x0FFFFFFF bytes (256MB), the protozero limit.
+  private static final int NESTED_LENGTH_FIELD_SIZE = 4;
+  private static final int MAX_NESTING_DEPTH = 16;
+  private static final int UTF8_SCRATCH_SIZE = 512;
+  // Small by default (a track-event body is well under this); grows on demand.
+  // Kept small so that hundreds of per-thread writers stay cheap.
+  private static final int DEFAULT_BUFFER_SIZE = 512;
+
+  private byte[] mBuf;
+  private int mPos;
+  private final int[] mNestingStack = new int[MAX_NESTING_DEPTH];
+  private int mNestingDepth;
+  private final byte[] mUtf8Scratch = new byte[UTF8_SCRATCH_SIZE];
+
+  public ProtoWriter() {
+    this(DEFAULT_BUFFER_SIZE);
+  }
+
+  public ProtoWriter(int bufferSize) {
+    mBuf = new byte[bufferSize];
+  }
+
+  /** Resets the write position so the buffer can be reused. No allocation. */
+  public void reset() {
+    mPos = 0;
+    mNestingDepth = 0;
+  }
+
+  /** Number of bytes written so far. */
+  public int position() {
+    return mPos;
+  }
+
+  /** The backing buffer. Valid data spans {@code [0, position())}. */
+  public byte[] buffer() {
+    return mBuf;
+  }
+
+  /** Writes a uint32/uint64/int32/int64/enum field. */
+  public void writeVarInt(int fieldId, long value) {
+    ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN);
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_VARINT));
+    putVarIntNoCheck(value);
+  }
+
+  /** Writes a bool field. */
+  public void writeBool(int fieldId, boolean value) {
+    ensureCapacity(MAX_TAG_LEN + 1);
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_VARINT));
+    mBuf[mPos++] = (byte) (value ? 1 : 0);
+  }
+
+  /** Writes a fixed64/sfixed64 field. */
+  public void writeFixed64(int fieldId, long value) {
+    ensureCapacity(MAX_TAG_LEN + 8);
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_FIXED64));
+    putLongLeNoCheck(value);
+  }
+
+  /** Writes a double field. */
+  public void writeDouble(int fieldId, double value) {
+    writeFixed64(fieldId, Double.doubleToRawLongBits(value));
+  }
+
+  /**
+   * Writes a string field. Uses an ASCII fast path when every char is {@code <=
+   * 0x7F} (the common case for event names, categories and arg keys), falling
+   * back to UTF-8 encoding otherwise.
+   */
+  public void writeString(int fieldId, String value) {
+    int len = value.length();
+    // tag + length varint + the bytes, ensured in one shot. ASCII is the
+    // overwhelmingly common case (event names, categories, arg keys), where the
+    // byte length equals the char length: write that length, then check-and-copy
+    // each char in a SINGLE pass (the previous code did one pass to verify ASCII
+    // and a second to copy). There is no alloc-free bulk String->byte[] copy on
+    // ART -- libcore's String.getBytes is itself a charAt loop, and
+    // getBytes(charset) allocates -- so one combined pass over the chars is the
+    // floor. A non-ASCII char rewinds and re-encodes the whole field as UTF-8.
+    ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN + len);
+    int start = mPos;
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED));
+    putVarIntNoCheck(len);
+    byte[] buf = mBuf;
+    int pos = mPos;
+    for (int i = 0; i < len; i++) {
+      char c = value.charAt(i);
+      if (c > 0x7F) {
+        mPos = start; // discard the optimistic tag/len/bytes; redo as UTF-8.
+        writeStringUtf8(fieldId, value);
+        return;
+      }
+      buf[pos++] = (byte) c;
+    }
+    mPos = pos;
+  }
+
+  /**
+   * Begins a nested length-delimited message. Writes the field tag and reserves
+   * {@link #NESTED_LENGTH_FIELD_SIZE} bytes for the length, back-filled by the
+   * matching {@link #endNested}. Nesting is strictly LIFO.
+   */
+  public void beginNested(int fieldId) {
+    ensureCapacity(MAX_TAG_LEN + NESTED_LENGTH_FIELD_SIZE);
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED));
+    mNestingStack[mNestingDepth++] = mPos;
+    mPos += NESTED_LENGTH_FIELD_SIZE;
+  }
+
+  /**
+   * Ends the innermost nested message started with {@link #beginNested}, back-
+   * filling its reserved 4-byte redundant varint with the body length. The
+   * redundant form keeps the prefix a fixed width regardless of the value,
+   * avoiding a second pass to compute sizes.
+   */
+  public void endNested() {
+    int bookmark = mNestingStack[--mNestingDepth];
+    int size = mPos - bookmark - NESTED_LENGTH_FIELD_SIZE;
+    byte[] buf = mBuf;
+    buf[bookmark] = (byte) ((size & 0x7F) | 0x80);
+    buf[bookmark + 1] = (byte) (((size >> 7) & 0x7F) | 0x80);
+    buf[bookmark + 2] = (byte) (((size >> 14) & 0x7F) | 0x80);
+    buf[bookmark + 3] = (byte) ((size >> 21) & 0x7F);
+  }
+
+  // The *NoCheck writers assume the caller has already ensured capacity for the
+  // bytes they write; they hoist mBuf/mPos to locals.
+
+  private static long makeTag(int fieldId, int wireType) {
+    return ((long) fieldId << 3) | wireType;
+  }
+
+  private void putVarIntNoCheck(long value) {
+    byte[] buf = mBuf;
+    int pos = mPos;
+    while ((value & ~0x7FL) != 0) {
+      buf[pos++] = (byte) ((value & 0x7F) | 0x80);
+      value >>>= 7;
+    }
+    buf[pos++] = (byte) value;
+    mPos = pos;
+  }
+
+  private void putLongLeNoCheck(long value) {
+    byte[] buf = mBuf;
+    int pos = mPos;
+    buf[pos] = (byte) value;
+    buf[pos + 1] = (byte) (value >> 8);
+    buf[pos + 2] = (byte) (value >> 16);
+    buf[pos + 3] = (byte) (value >> 24);
+    buf[pos + 4] = (byte) (value >> 32);
+    buf[pos + 5] = (byte) (value >> 40);
+    buf[pos + 6] = (byte) (value >> 48);
+    buf[pos + 7] = (byte) (value >> 56);
+    mPos = pos + 8;
+  }
+
+  private void ensureCapacity(int needed) {
+    if (mPos + needed <= mBuf.length) {
+      return;
+    }
+    int newSize = Math.max(mBuf.length * 2, mPos + needed);
+    byte[] newBuf = new byte[newSize];
+    System.arraycopy(mBuf, 0, newBuf, 0, mPos);
+    mBuf = newBuf;
+  }
+
+  private void writeStringUtf8(int fieldId, String s) {
+    int utf8Len = encodeUtf8(s, mUtf8Scratch);
+    byte[] src;
+    int srcLen;
+    if (utf8Len >= 0) {
+      src = mUtf8Scratch;
+      srcLen = utf8Len;
+    } else {
+      // Scratch too small (long non-ASCII string). Rare; allocate exactly once.
+      src = new byte[-utf8Len];
+      srcLen = encodeUtf8(s, src);
+    }
+    ensureCapacity(MAX_TAG_LEN + MAX_VARINT_LEN + srcLen);
+    putVarIntNoCheck(makeTag(fieldId, WIRE_TYPE_DELIMITED));
+    putVarIntNoCheck(srcLen);
+    System.arraycopy(src, 0, mBuf, mPos, srcLen);
+    mPos += srcLen;
+  }
+
+  /**
+   * Encodes {@code s} as UTF-8 into {@code dst}. Returns the byte count on
+   * success, or {@code -needed} (a negative lower bound on the required size) if
+   * {@code dst} is too small.
+   */
+  private static int encodeUtf8(String s, byte[] dst) {
+    int len = s.length();
+    int dp = 0;
+    for (int i = 0; i < len; i++) {
+      char c = s.charAt(i);
+      if (c <= 0x7F) {
+        if (dp >= dst.length) {
+          return -(len * 3);
+        }
+        dst[dp++] = (byte) c;
+      } else if (c <= 0x7FF) {
+        if (dp + 2 > dst.length) {
+          return -(len * 3);
+        }
+        dst[dp++] = (byte) (0xC0 | (c >> 6));
+        dst[dp++] = (byte) (0x80 | (c & 0x3F));
+      } else if (Character.isHighSurrogate(c) && i + 1 < len) {
+        char low = s.charAt(++i);
+        int cp = Character.toCodePoint(c, low);
+        if (dp + 4 > dst.length) {
+          return -(len * 4);
+        }
+        dst[dp++] = (byte) (0xF0 | (cp >> 18));
+        dst[dp++] = (byte) (0x80 | ((cp >> 12) & 0x3F));
+        dst[dp++] = (byte) (0x80 | ((cp >> 6) & 0x3F));
+        dst[dp++] = (byte) (0x80 | (cp & 0x3F));
+      } else {
+        if (dp + 3 > dst.length) {
+          return -(len * 3);
+        }
+        dst[dp++] = (byte) (0xE0 | (c >> 12));
+        dst[dp++] = (byte) (0x80 | ((c >> 6) & 0x3F));
+        dst[dp++] = (byte) (0x80 | (c & 0x3F));
+      }
+    }
+    return dp;
+  }
+}
diff --git a/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java b/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java
index 46399dd..1ce3db7 100644
--- a/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java
+++ b/src/android_sdk/java/test/dev/perfetto/sdk/test/PerfettoTraceTest.java
@@ -25,7 +25,6 @@
 import android.util.ArraySet;
 import android.util.Log;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
-import dev.perfetto.sdk.PerfettoNativeMemoryCleaner.AllocationStats;
 import dev.perfetto.sdk.PerfettoTrace;
 import dev.perfetto.sdk.PerfettoTrack;
 import dev.perfetto.sdk.PerfettoTrackEventBuilder;
@@ -84,8 +83,6 @@
     // 'var unused' suppress error-prone warning
     var unused = FOO_CATEGORY.register();
 
-    PerfettoTrackEventBuilder.getNativeAllocationStats().reset();
-
     mCategoryNames.clear();
     mEventNames.clear();
     mDebugAnnotationNames.clear();
@@ -93,42 +90,6 @@
   }
 
   @Test
-  public void testFreeNativeMemoryWhenJavaObjectGCed() {
-    TraceConfig traceConfig = getTraceConfig(FOO);
-    PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray());
-    for (int i = 0; i < 600_000; i++) {
-      String eventName = "event_" + i;
-      String nativeStringArgKey = "string_key_" + i;
-      String nativeStringValue = "string_value_" + i;
-      // Create a large amount of 'ArgString' objects in heap to trigger GC, no need to emit them.
-      PerfettoTrace.instant(FOO_CATEGORY, eventName).addArg(nativeStringArgKey, nativeStringValue);
-    }
-
-    // Manually trigger GC if creating 600_000 objects was not enough.
-    for (int i = 0; i < 10; i++) {
-      System.runFinalization();
-      System.gc();
-    }
-
-    // We ignore the trace content.
-    byte[] traceBytes = session.close();
-    assertThat(traceBytes).isNotEmpty();
-
-    // We test that the GC triggers 'free native memory' function when the corresponding java
-    // objects are garbage collected.
-    AllocationStats allocationStats = PerfettoTrackEventBuilder.getNativeAllocationStats();
-    String argClsName = "dev.perfetto.sdk.PerfettoTrackEventExtra$Arg";
-    assertThat(allocationStats.getAllocCountForTarget(argClsName)).isEqualTo(600_000);
-    // Assert that the native memory was freed at least once.
-    // In practice the counter is usually greater than 300_000 if not manually trigger GC,
-    // and 599_995 (600_000 - dev.perfetto.sdk.PerfettoTrackEventBuilder#DEFAULT_EXTRA_CACHE_SIZE)
-    // if do manually trigger.
-    assertThat(allocationStats.getFreeCountForTarget(argClsName)).isGreaterThan(0);
-    String allocDebugStats = allocationStats.reportStats();
-    Log.d(TAG, "Memory cleaner allocation stats: " + allocDebugStats);
-  }
-
-  @Test
   public void testCategoryWithTags() throws Exception {
     Category category = new Category("MyCategory", List.of("MyTag", "MyOtherTag")).register();
     TraceConfig traceConfig = getTraceConfig(null, List.of("MyTag"));
@@ -224,11 +185,11 @@
     PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray());
 
     PerfettoTrace.begin(FOO_CATEGORY, "event")
-        .usingNamedTrack(123, FOO, PerfettoTrace.getProcessTrackUuid())
+        .usingProcessNamedTrack(123, FOO)
         .emit();
 
     PerfettoTrace.end(FOO_CATEGORY)
-        .usingNamedTrack(456, "bar", PerfettoTrace.getThreadTrackUuid(Process.myTid()))
+        .usingThreadNamedTrack(456, "bar", Process.myTid())
         .emit();
 
     Trace trace = Trace.parseFrom(session.close());
@@ -303,6 +264,49 @@
   }
 
   @Test
+  public void testThreeLevelNestedTrack() throws Exception {
+    TraceConfig traceConfig = getTraceConfig(FOO);
+
+    PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray());
+
+    PerfettoTrack a = PerfettoTrack.process("A");
+    PerfettoTrack b = a.child("B");
+    PerfettoTrack c = b.child("C");
+    PerfettoTrace.instant(FOO_CATEGORY, "event").usingTrack(c).emit();
+
+    Trace trace = Trace.parseFrom(session.close());
+
+    Map<Long, TrackDescriptor> descriptorsByUuid = new HashMap<>();
+    long eventTrackUuid = 0;
+    for (TracePacket packet : trace.getPacketList()) {
+      if (packet.hasTrackDescriptor()) {
+        TrackDescriptor td = packet.getTrackDescriptor();
+        descriptorsByUuid.put(td.getUuid(), td);
+      }
+      if (packet.hasTrackEvent()
+          && TrackEvent.Type.TYPE_INSTANT.equals(packet.getTrackEvent().getType())
+          && packet.getTrackEvent().hasTrackUuid()) {
+        eventTrackUuid = packet.getTrackEvent().getTrackUuid();
+      }
+    }
+
+    // Walk the chain leaf -> root: C -> B -> A -> process track.
+    TrackDescriptor cTd = descriptorsByUuid.get(eventTrackUuid);
+    assertThat(cTd).isNotNull();
+    assertThat(cTd.getName()).isEqualTo("C");
+
+    TrackDescriptor bTd = descriptorsByUuid.get(cTd.getParentUuid());
+    assertThat(bTd).isNotNull();
+    assertThat(bTd.getName()).isEqualTo("B");
+
+    TrackDescriptor aTd = descriptorsByUuid.get(bTd.getParentUuid());
+    assertThat(aTd).isNotNull();
+    assertThat(aTd.getName()).isEqualTo("A");
+
+    assertThat(aTd.getParentUuid()).isEqualTo(PerfettoTrace.getProcessTrackUuid());
+  }
+
+  @Test
   public void testGlobalNestedTrack() throws Exception {
     TraceConfig traceConfig = getTraceConfig(FOO);
 
@@ -314,7 +318,6 @@
 
     Trace trace = Trace.parseFrom(session.close());
 
-    // Index every track descriptor by its uuid and capture the event's track.
     Map<Long, TrackDescriptor> descriptorsByUuid = new HashMap<>();
     long eventTrackUuid = 0;
     for (TracePacket packet : trace.getPacketList()) {
@@ -541,11 +544,11 @@
     PerfettoTrace.Session session = new PerfettoTrace.Session(true, traceConfig.toByteArray());
 
     PerfettoTrace.counter(FOO_CATEGORY, 16)
-        .usingCounterTrack(PerfettoTrace.getProcessTrackUuid(), FOO)
+        .usingProcessCounterTrack(FOO)
         .emit();
 
     PerfettoTrace.counter(FOO_CATEGORY, 3.14)
-        .usingCounterTrack(PerfettoTrace.getThreadTrackUuid(Process.myTid()), "bar")
+        .usingThreadCounterTrack(Process.myTid(), "bar")
         .emit();
 
     Trace trace = Trace.parseFrom(session.close());
@@ -1008,6 +1011,17 @@
   }
 
   private void collectInternedData(TracePacket packet) {
+    // Debug-arg names are written inline (DebugAnnotation.name), not interned --
+    // the body is encoded in Java before emit, so it can't know the per-sequence
+    // name_iid. Collect the inline names from the track event itself.
+    if (packet.hasTrackEvent()) {
+      for (DebugAnnotation dbg : packet.getTrackEvent().getDebugAnnotationsList()) {
+        if (!dbg.getName().isEmpty()) {
+          mDebugAnnotationNames.add(dbg.getName());
+        }
+      }
+    }
+
     if (!packet.hasInternedData()) {
       return;
     }
diff --git a/src/android_sdk/jni/BUILD.gn b/src/android_sdk/jni/BUILD.gn
index f52387e..74424c0 100644
--- a/src/android_sdk/jni/BUILD.gn
+++ b/src/android_sdk/jni/BUILD.gn
@@ -10,6 +10,7 @@
   "dev_perfetto_sdk_PerfettoTrackEventExtra.cc",
   "dev_perfetto_sdk_PerfettoTrackEventExtra.h",
   "macros.h",
+  "string_buffer.h",
 ]
 
 #TODO(ktimofeev): use builtin 'nativehelper' lib when building in AOSP.
diff --git a/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc b/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc
index d57199e..26fc504 100644
--- a/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc
+++ b/src/android_sdk/jni/dev_perfetto_sdk_PerfettoTrackEventExtra.cc
@@ -498,14 +498,60 @@
                                                           jint type,
                                                           jlong cat_ptr,
                                                           jstring name,
-                                                          jlong extra_ptr) {
+                                                          jlong extra_ptr,
+                                                          jlong raw_body_ptr,
+                                                          jbyteArray body,
+                                                          jint body_len) {
+  auto* raw_body = toPointer<sdk_for_jni::RawBody>(raw_body_ptr);
+  if (body_len > 0) {
+    uint8_t* dst = raw_body->reserve_body(static_cast<size_t>(body_len));
+    env->GetByteArrayRegion(body, 0, body_len, reinterpret_cast<jbyte*>(dst));
+  }
   sdk_for_jni::Category* category = toPointer<sdk_for_jni::Category>(cat_ptr);
   trace_event(type, category->get(),
               StringBuffer::utf16_to_ascii(env, name).data(),
               toPointer<sdk_for_jni::Extra>(extra_ptr));
   StringBuffer::reset();
+  raw_body->reset_after_emit();
 }
 
+static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_init(
+    PERFETTO_JNI_HOST_PARAMS) {
+  return toJLong(new sdk_for_jni::RawBody());
+}
+
+static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_delete(
+    PERFETTO_JNI_HOST_PARAMS) {
+  return toJLong(&sdk_for_jni::RawBody::delete_raw_body);
+}
+
+static jlong dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_get_extra_ptr(
+    PERFETTO_JNI_HOST_PARAMS_COMMA jlong ptr) {
+  return toJLong(toPointer<sdk_for_jni::RawBody>(ptr)->get());
+}
+
+static void dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_add_interned(
+    JNIEnv* env,
+    jclass,
+    jlong ptr,
+    jlong id,
+    jstring val,
+    jlong interned_type_id) {
+  toPointer<sdk_for_jni::RawBody>(ptr)->add_interned(
+      static_cast<uint32_t>(id), StringBuffer::utf16_to_ascii(env, val).data(),
+      static_cast<uint32_t>(interned_type_id));
+}
+
+static const JNINativeMethod gRawBodyMethods[] = {
+    {"native_init", "()J",
+     (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_init},
+    {"native_add_interned", "(JJLjava/lang/String;J)V",
+     (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_add_interned},
+    {"native_delete", "()J",
+     (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_delete},
+    {"native_get_extra_ptr", "(J)J",
+     (void*)dev_perfetto_sdk_PerfettoTrackEventExtraRawBody_get_extra_ptr}};
+
 static jlong dev_perfetto_sdk_PerfettoTrackEventExtraProto_init(
     PERFETTO_JNI_HOST_PARAMS) {
   return toJLong(new sdk_for_jni::Proto());
@@ -544,7 +590,7 @@
      (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_add_arg},
     {"native_clear_args", "(J)V",
      (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_clear_args},
-    {"native_emit", "(IJLjava/lang/String;J)V",
+    {"native_emit", "(IJLjava/lang/String;JJ[BI)V",
      (void*)dev_perfetto_sdk_PerfettoTrackEventExtra_emit}};
 
 static const JNINativeMethod gProtoMethods[] = {
@@ -723,6 +769,12 @@
       TO_MAYBE_JAR_JAR_CLASS_NAME(
           "dev/perfetto/sdk/PerfettoTrackEventExtra$CounterTrack"),
       gCounterTrackMethods, NELEM(gCounterTrackMethods));
+  res = jniRegisterNativeMethods(
+      env,
+      TO_MAYBE_JAR_JAR_CLASS_NAME(
+          "dev/perfetto/sdk/PerfettoTrackEventExtra$RawBody"),
+      gRawBodyMethods, NELEM(gRawBodyMethods));
+  LOG_ALWAYS_FATAL_IF(res < 0, "Unable to register raw body native methods.");
   LOG_ALWAYS_FATAL_IF(res < 0,
                       "Unable to register counter track native methods.");
 
diff --git a/src/android_sdk/jni/string_buffer.h b/src/android_sdk/jni/string_buffer.h
new file mode 100644
index 0000000..0b8e930
--- /dev/null
+++ b/src/android_sdk/jni/string_buffer.h
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed 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.
+ */
+
+#ifndef SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_
+#define SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_
+
+#include <jni.h>
+
+#include <algorithm>
+#include <list>
+#include <string>
+#include <string_view>
+
+namespace perfetto {
+namespace jni {
+
+/**
+ * @brief A thread-safe utility class for converting Java UTF-16 strings to
+ * ASCII in a JNI environment.
+ *
+ * StringBuffer provides efficient conversion of Java strings to ASCII with
+ * optimized memory handling. It uses a two-tiered buffering strategy:
+ * 1. A fast path using pre-allocated thread-local buffers for strings up to 128
+ * characters.
+ * 2. A fallback path using dynamic allocation for longer strings.
+ *
+ * Non-ASCII characters (>255) are replaced with '?' during conversion. The
+ * class maintains thread safety through thread-local storage and provides
+ * zero-copy string views for optimal performance.
+ *
+ * Crucially, conversions allocate no per-call native memory in the common case,
+ * unlike GetStringUTFChars/ScopedUtfChars. All views returned within a single
+ * trace event remain valid until reset() is called (typically right after the
+ * event is emitted).
+ *
+ * Thread Safety: All methods are thread-safe due to thread-local storage.
+ */
+class StringBuffer {
+ private:
+  static constexpr size_t BASE_SIZE = 128;
+  // Temporarily stores the UTF-16 characters retrieved from the Java
+  // string before they are converted to ASCII.
+  static thread_local inline char char_buffer[BASE_SIZE];
+  // For fast-path conversions when the resulting ASCII string fits within
+  // the pre-allocated space. All ascii strings in a trace event will be stored
+  // here until emitted.
+  static thread_local inline jchar jchar_buffer[BASE_SIZE];
+  // When the fast-path conversion is not possible (because char_buffer
+  // doesn't have enough space), the converted ASCII string is stored
+  // in this list. We use list here to avoid moving the strings on resize
+  // with vector. This way, we can give out string_views from the stored
+  // strings. The additional overhead from list node allocations is fine cos we
+  // are already in an extremely unlikely path here and there are other bigger
+  // problems if here.
+  static thread_local inline std::list<std::string> overflow_strings;
+  // current offset into the char_buffer.
+  static thread_local inline size_t current_offset{0};
+  // This allows us avoid touching the overflow_strings directly in the fast
+  // path. Touching it causes some thread local init routine to run which shows
+  // up in profiles.
+  static thread_local inline bool is_overflow_strings_empty = true;
+
+  static void copy_utf16_to_ascii(const jchar* src,
+                                  size_t len,
+                                  char* dst,
+                                  JNIEnv* env,
+                                  jstring str) {
+    std::transform(src, src + len, dst, [](jchar c) {
+      return (c <= 0xFF) ? static_cast<char>(c) : '?';
+    });
+
+    if (src != jchar_buffer) {
+      // We hit the slow path to populate src, so we have to release.
+      env->ReleaseStringCritical(str, src);
+    }
+  }
+
+ public:
+  static void reset() {
+    if (!is_overflow_strings_empty) {
+      overflow_strings.clear();
+      is_overflow_strings_empty = true;
+    }
+    current_offset = 0;
+  }
+
+  // Converts a Java string (jstring) to an ASCII string_view. Characters
+  // outside the ASCII range (0-255) are replaced with '?'.
+  //
+  // @param env The JNI environment.
+  // @param val The Java string to convert.
+  // @return A string_view representing the ASCII version of the string.
+  //         Returns an empty string_view if the input is null or empty.
+  static std::string_view utf16_to_ascii(JNIEnv* env, jstring val) {
+    if (!val)
+      return "";
+
+    const jsize len = env->GetStringLength(val);
+    if (len == 0)
+      return "";
+
+    const jchar* temp_buffer;
+
+    // Fast path: Enough space in jchar_buffer
+    if (static_cast<size_t>(len) <= BASE_SIZE) {
+      env->GetStringRegion(val, 0, len, jchar_buffer);
+      temp_buffer = jchar_buffer;
+    } else {
+      // Slow path: Fallback to asking ART for the string which will likely
+      // allocate and return a copy.
+      temp_buffer = env->GetStringCritical(val, nullptr);
+    }
+
+    const size_t next_offset = current_offset + static_cast<size_t>(len) + 1;
+    // Fast path: Enough space in char_buffer
+    if (BASE_SIZE > next_offset) {
+      copy_utf16_to_ascii(temp_buffer, static_cast<size_t>(len),
+                          char_buffer + current_offset, env, val);
+      char_buffer[current_offset + static_cast<size_t>(len)] = '\0';
+
+      auto res =
+          std::string_view(char_buffer + current_offset, static_cast<size_t>(len));
+      current_offset = next_offset;
+      return res;
+    } else {
+      // Slow path: Not enough space in char_buffer. Use overflow_strings.
+      // This will cause a string alloc but should be very unlikely to hit.
+      std::string& str =
+          overflow_strings.emplace_back(static_cast<size_t>(len) + 1, '\0');
+
+      copy_utf16_to_ascii(temp_buffer, static_cast<size_t>(len), str.data(), env,
+                          val);
+      is_overflow_strings_empty = false;
+      return std::string_view(str);
+    }
+  }
+};
+
+}  // namespace jni
+}  // namespace perfetto
+
+#endif  // SRC_ANDROID_SDK_JNI_STRING_BUFFER_H_
diff --git a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h
index 34547cd..b4c532d 100644
--- a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h
+++ b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h
@@ -261,6 +261,90 @@
   PerfettoTeHlExtraCounterUnion counter_;
 };
 
+// The single extra carrying everything the Java side encodes for a track event:
+// the pre-serialized body (debug args, flows, counter value, plain proto fields)
+// spliced in verbatim as one RAW proto field, plus any interned string fields.
+// All ride one PROTO_FIELDS extra. The body bytes are copied once, on the
+// Java->native crossing, into this object's own native buffer under a @FastNative
+// method (which skips the JNI thread-state transition that dominates a small
+// copy). Interned fields can't live in the verbatim body -- their iid is
+// per-sequence native state -- so they are carried as CSTR_INTERNED fields here
+// and interned natively at emit time, alongside the raw body.
+class RawBody {
+ public:
+  RawBody() {
+    raw_.header.type = PERFETTO_TE_HL_PROTO_TYPE_RAW;
+    raw_.header.id = 0;
+    raw_.buf = nullptr;
+    raw_.len = 0;
+    proto_.header.type = PERFETTO_TE_HL_EXTRA_TYPE_PROTO_FIELDS;
+    rebuild();
+  }
+
+  // Ensures the native buffer can hold len bytes, sets raw_.len, and returns a
+  // writable pointer the JNI copy fills from the Java byte[]. raw_.buf is kept
+  // in sync with the (possibly reallocated) buffer.
+  uint8_t* reserve_body(size_t len) {
+    if (len > buf_.size()) {
+      buf_.resize(len);
+    }
+    raw_.buf = buf_.data();
+    raw_.len = len;
+    return buf_.data();
+  }
+
+  // Adds an interned string proto field carried alongside the raw body and
+  // interned natively at emit time. Interned fields are rare, so this per-field
+  // crossing is fine; events without one pay nothing.
+  void add_interned(uint32_t id, const char* str, uint32_t interned_type_id) {
+    interned_strs_.emplace_back(str);
+    PerfettoTeHlProtoFieldCstrInterned field{};
+    field.header.type = PERFETTO_TE_HL_PROTO_TYPE_CSTR_INTERNED;
+    field.header.id = id;
+    field.interned_type_id = interned_type_id;
+    interned_.push_back(field);
+    rebuild();
+  }
+
+  // Clears per-event state after the emit consumes it. Cheap on the common path
+  // (no interned fields): a single store plus an empty check.
+  void reset_after_emit() {
+    raw_.len = 0;
+    if (!interned_.empty()) {
+      interned_.clear();
+      interned_strs_.clear();
+      rebuild();
+    }
+  }
+
+  static void delete_raw_body(RawBody* raw_body) { delete raw_body; }
+
+  const PerfettoTeHlExtraProtoFields* get() const { return &proto_; }
+
+ private:
+  // Rebuilds the NULL-terminated field list (the raw body field, then each
+  // interned field). Re-derives every pointer from current storage, so it is
+  // safe after a vector reallocation.
+  void rebuild() {
+    fields_.clear();
+    fields_.push_back(&raw_.header);
+    for (size_t i = 0; i < interned_.size(); ++i) {
+      interned_[i].str = interned_strs_[i].c_str();
+      fields_.push_back(&interned_[i].header);
+    }
+    fields_.push_back(nullptr);
+    proto_.fields = fields_.data();
+  }
+
+  DISALLOW_COPY_AND_ASSIGN(RawBody);
+  PerfettoTeHlExtraProtoFields proto_;
+  PerfettoTeHlProtoFieldRaw raw_;
+  std::vector<uint8_t> buf_;
+  std::vector<PerfettoTeHlProtoFieldCstrInterned> interned_;
+  std::vector<std::string> interned_strs_;
+  std::vector<PerfettoTeHlProtoField*> fields_;
+};
+
 /**
  * @brief Represents a debug argument for a trace event.
  */
diff --git a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc
index d3e3c55..3564962 100644
--- a/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc
+++ b/src/android_sdk/perfetto_sdk_for_jni/tracing_sdk_unittest.cc
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+#include <cstring>
+
 #include "test/gtest_and_gmock.h"
 
 #include "src/android_sdk/perfetto_sdk_for_jni/tracing_sdk.h"
@@ -117,25 +119,18 @@
 
   auto tracing_session = StartTracing();
 
-  // In this test we generate a named slice with an additional payload
-
-  sdk_for_jni::DebugArg player_number_extra("player_number");
-  player_number_extra.get()->arg_int64.header.type =
-      PERFETTO_TE_HL_EXTRA_TYPE_DEBUG_ARG_INT64;
-  player_number_extra.get()->arg_int64.name = player_number_extra.name();
-  player_number_extra.get()->arg_int64.value = 42;
-
-  sdk_for_jni::DebugArg player_alive_extra("player_alive");
-  player_alive_extra.get()->arg_bool.header.type =
-      PERFETTO_TE_HL_EXTRA_TYPE_DEBUG_ARG_BOOL;
-  player_alive_extra.get()->arg_bool.name = player_alive_extra.name();
-  player_alive_extra.get()->arg_bool.value = true;
+  // Debug args / proto fields are now encoded into the body on the Java side and
+  // handed to native via RawBody, which splices them verbatim onto the event as
+  // one RAW proto field. Here we hand RawBody the wire bytes of
+  // `debug_annotations { int_value: 42 }` (TrackEvent field 4, length 2, holding
+  // DebugAnnotation field 4 = int_value, varint 42).
+  static const uint8_t kBody[] = {0x22, 0x02, 0x20, 0x2a};
+  sdk_for_jni::RawBody body;
+  std::memcpy(body.reserve_body(sizeof(kBody)), kBody, sizeof(kBody));
 
   sdk_for_jni::Extra extra;
   extra.push_extra(reinterpret_cast<PerfettoTeHlExtra*>(
-      &player_number_extra.get()->arg_int64));
-  extra.push_extra(reinterpret_cast<PerfettoTeHlExtra*>(
-      &player_alive_extra.get()->arg_bool));
+      const_cast<PerfettoTeHlExtraProtoFields*>(body.get())));
   trace_event(PERFETTO_TE_TYPE_SLICE_BEGIN, category.get(), "DrawPlayer",
               &extra);
 
@@ -153,8 +148,8 @@
   }
 
   const char* actual = R"(packet {
-data { categories: [rendering] names: [DrawPlayer], debug_annotation_names: [player_number, player_alive] }
-event { type: 1, debug_annotations: [int: 42, bool: 1] }
+data { categories: [rendering] names: [DrawPlayer], debug_annotation_names: [] }
+event { type: 1, debug_annotations: [int: 42] }
 }
 packet {
 event { type: 2, debug_annotations: [] }
diff --git a/src/shared_lib/track_event/hl.cc b/src/shared_lib/track_event/hl.cc
index 889c411..b1e8446 100644
--- a/src/shared_lib/track_event/hl.cc
+++ b/src/shared_lib/track_event/hl.cc
@@ -81,6 +81,11 @@
         msg->AppendBytes(field->header.id, field->buf, field->len);
         break;
       }
+      case PERFETTO_TE_HL_PROTO_TYPE_RAW: {
+        auto field = reinterpret_cast<PerfettoTeHlProtoFieldRaw*>(*p);
+        msg->AppendRawProtoBytes(field->buf, field->len);
+        break;
+      }
       case PERFETTO_TE_HL_PROTO_TYPE_NESTED: {
         auto field = reinterpret_cast<PerfettoTeHlProtoFieldNested*>(*p);
         auto* nested =