Repository: stellar/rs-soroban-env
Pull request #1324 (2024-01-12 06:06:29)

Tracing
This PR splits the existing "observation" machinery out into a separate "tracing" subsystem that uses the shadow budget to reduce DoS risk if it's ever activated in production. It therefore includes a (manual, awful) metered hash implementation of everything that gets hashed in that machinery. It also includes some reorganization of the code and general reduction of unnecessary allocations.

There'll be a corresponding change to core to route this tracing output into the core logging system. There are maybe a couple things left to clean up tomorrow but it's good enough to be reviewed now:

  - [x] Remove the json observation files for the tests un-observed
  - [x] Maybe add an `EnvBase::tracing_enabled() -> bool` that we can use to short-circuit composing the arg arrays in general


Patch:
diff --git a/cackle.toml b/cackle.toml
index 5fc37d7c..e496829b 100644
--- a/cackle.toml
+++ b/cackle.toml
@@ -22,6 +22,9 @@ include = [
     "std::sys::unix::rand::hashmap_random_keys",
     "hashbrown::raw",
 ]
+no_auto_detect = [
+    "soroban-env-common",
+]
 
 [api.rand]
 include = [
@@ -410,4 +413,4 @@ from.build.allow_apis = [
     "env",
     "fs",
     "process",
-]
\ No newline at end of file
+]
diff --git a/soroban-env-common/Cargo.toml b/soroban-env-common/Cargo.toml
index 4828fd2d..7d058e7f 100644
--- a/soroban-env-common/Cargo.toml
+++ b/soroban-env-common/Cargo.toml
@@ -38,6 +38,7 @@ wasmi = ["dep:wasmi"]
 testutils = ["dep:arbitrary", "stellar-xdr/arbitrary"]
 next = ["stellar-xdr/next", "soroban-env-macros/next"]
 tracy = ["dep:tracy-client"]
+shallow-val-hash = []
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/soroban-env-common/src/env.rs b/soroban-env-common/src/env.rs
index 3f9b143f..9c8501ea 100644
--- a/soroban-env-common/src/env.rs
+++ b/soroban-env-common/src/env.rs
@@ -76,16 +76,14 @@ pub trait EnvBase: Sized + Clone {
     #[cfg(feature = "testutils")]
     fn escalate_error_to_panic(&self, e: Self::Error) -> !;
 
-    /// A general interface for tracing all env-method calls, intended to
-    /// be called from macros that do dispatch on all such methods.
     #[cfg(all(feature = "std", feature = "testutils"))]
+    #[deprecated(note = "replaced by trace_env_call")]
     fn env_call_hook(&self, _fname: &'static str, _args: &[String]) -> Result<(), Self::Error> {
         Ok(())
     }
 
-    /// A general interface for tracing all env-method returns, intended to
-    /// be called from macros that do dispatch on all such methods.
     #[cfg(all(feature = "std", feature = "testutils"))]
+    #[deprecated(note = "replaced by trace_env_ret")]
     fn env_ret_hook(
         &self,
         _fname: &'static str,
@@ -94,6 +92,35 @@ pub trait EnvBase: Sized + Clone {
         Ok(())
     }
 
+    /// Return true if the environment wants to receive trace calls and and
+    /// returns using [`Self::trace_env_call`] and [`Self::trace_env_ret`].
+    #[cfg(feature = "std")]
+    fn tracing_enabled(&self) -> bool {
+        false
+    }
+
+    /// A general interface for tracing all env-method calls, intended to
+    /// be called from macros that do dispatch on all such methods.
+    #[cfg(feature = "std")]
+    fn trace_env_call(
+        &self,
+        _fname: &'static str,
+        _args: &[&dyn core::fmt::Debug],
+    ) -> Result<(), Self::Error> {
+        Ok(())
+    }
+
+    /// A general interface for tracing all env-method returns, intended to
+    /// be called from macros that do dispatch on all such methods.
+    #[cfg(feature = "std")]
+    fn trace_env_ret(
+        &self,
+        _fname: &'static str,
+        _res: &Result<&dyn core::fmt::Debug, &Self::Error>,
+    ) -> Result<(), Self::Error> {
+        Ok(())
+    }
+
     /// If `x` is `Err(...)`, ensure as much debug information as possible is
     /// attached to that error; in any case return "essentially the same" `x` --
     /// either `Ok(...)` or `Err(...)` -- just with extra error context.
diff --git a/soroban-env-common/src/hash.rs b/soroban-env-common/src/hash.rs
index c67bc426..b8c7a232 100644
--- a/soroban-env-common/src/hash.rs
+++ b/soroban-env-common/src/hash.rs
@@ -1,13 +1,13 @@
+//! These Hash impls exist strictly for use in soroban-env-host's tracing
+//! subsystem. Since they do not "look through" to the environment they are
+//! misleading and potentially dangerous to expose for general use.
+
 use crate::{
     AddressObject, BytesObject, DurationObject, I128Object, I256Object, I64Object, MapObject,
     Object, StringObject, Symbol, SymbolObject, TimepointObject, U128Object, U256Object, U64Object,
     Val, VecObject,
 };
 
-// These impls exist strictly for use in test observation. Since they do not
-// "look through" to the environment they are misleading and potentially
-// dangerous to expose for general use.
-
 impl core::hash::Hash for Val {
     fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
         self.get_payload().hash(state);
diff --git a/soroban-env-common/src/lib.rs b/soroban-env-common/src/lib.rs
index 58c1261a..937ee9fe 100644
--- a/soroban-env-common/src/lib.rs
+++ b/soroban-env-common/src/lib.rs
@@ -65,8 +65,15 @@ mod compare;
 mod convert;
 mod env;
 mod error;
-#[cfg(feature = "testutils")]
+
+// mod hash contains "shallow" impls of Hash for `Val` wrappers that are risky
+// to expose in general since they do not "look through" the env to their
+// underlying objects, instead hashing the object references themselves. This is
+// sufficiently dangerous that we guard it behind a specific feature that only
+// an informed consumer should enable.
+#[cfg(any(feature = "testutils", feature = "shallow-val-hash"))]
 mod hash;
+
 mod object;
 mod option;
 mod result;
diff --git a/soroban-env-common/src/vmcaller_env.rs b/soroban-env-common/src/vmcaller_env.rs
index 767865d6..5a56cd9b 100644
--- a/soroban-env-common/src/vmcaller_env.rs
+++ b/soroban-env-common/src/vmcaller_env.rs
@@ -179,22 +179,24 @@ macro_rules! vmcaller_none_function_helper {
         fn $fn_id(&self, $($arg:$type),*) -> Result<$ret, Self::Error> {
             #[cfg(all(not(target_family = "wasm"), feature = "tracy"))]
             let _span = tracy_span!(core::stringify!($fn_id));
-            #[cfg(all(feature = "std", feature = "testutils"))]
+            #[cfg(feature = "std")]
+            if self.tracing_enabled()
             {
-                self.env_call_hook(&core::stringify!($fn_id), &[$(format!("{:?}", $arg)),*])?;
+                self.trace_env_call(&core::stringify!($fn_id), &[$(&$arg),*])?;
             }
             let res: Result<_, _> = self.augment_err_result(<Self as VmCallerEnv>::$fn_id(self, &mut VmCaller::none(), $($arg.check_env_arg(self)?),*));
             let res = match res {
                 Ok(ok) => Ok(ok.check_env_arg(self)?),
                 Err(err) => Err(err)
             };
-            #[cfg(all(feature = "std", feature = "testutils"))]
+            #[cfg(feature = "std")]
+            if self.tracing_enabled()
             {
-                let res_str: Result<String,&Self::Error> = match &res {
-                    Ok(ok) => Ok(format!("{:?}", ok)),
+                let dyn_res: Result<&dyn core::fmt::Debug,&Self::Error> = match &res {
+                    Ok(ref ok) => Ok(ok),
                     Err(err) => Err(err)
                 };
-                self.env_ret_hook(&core::stringify!($fn_id), &res_str)?;
+                self.trace_env_ret(&core::stringify!($fn_id), &dyn_res)?;
             }
             res
         }
diff --git a/soroban-env-host/Cargo.toml b/soroban-env-host/Cargo.toml
index a97b13c5..c49baa41 100644
--- a/soroban-env-host/Cargo.toml
+++ b/soroban-env-host/Cargo.toml
@@ -15,7 +15,7 @@ exclude = ["observations/"]
 
 [dependencies]
 soroban-builtin-sdk-macros = { workspace = true }
-soroban-env-common = { workspace = true, features = ["std", "wasmi"] }
+soroban-env-common = { workspace = true, features = ["std", "wasmi", "shallow-val-hash"] }
 wasmi = { workspace = true }
 stellar-strkey = "=0.0.8"
 static_assertions = "=1.1.0"
diff --git a/soroban-env-host/src/auth.rs b/soroban-env-host/src/auth.rs
index 70fb250b..c5295b99 100644
--- a/soroban-env-host/src/auth.rs
+++ b/soroban-env-host/src/auth.rs
@@ -162,6 +162,7 @@ use crate::builtin_contracts::account_contract::{
 };
 use crate::builtin_contracts::invoker_contract_auth::invoker_contract_auth_to_authorized_invocation;
 use crate::host::metered_clone::{MeteredAlloc, MeteredClone, MeteredContainer, MeteredIterator};
+use crate::host::metered_hash::{CountingHasher, MeteredHash};
 use crate::host::Frame;
 use crate::host_object::HostVec;
 use crate::{Host, HostError};
@@ -290,8 +291,7 @@ enum AccountTrackersSnapshot {
     Recording(Vec<RefCell<AccountAuthorizationTracker>>),
 }
 
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 enum AuthorizationMode {
     Enforcing,
     #[cfg(any(test, feature = "recording_auth"))]
@@ -313,7 +313,7 @@ struct RecordingAuthInfo {
     disable_non_root_auth: bool,
 }
 
-#[cfg(feature = "testutils")]
+#[cfg(any(test, feature = "recording_auth"))]
 impl std::hash::Hash for RecordingAuthInfo {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         if let Ok(tracker_by_address_handle) = self.tracker_by_address_handle.try_borrow() {
@@ -345,8 +345,7 @@ impl RecordingAuthInfo {
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 enum MatchState {
     Unmatched,
     RootMatch,
@@ -400,8 +399,7 @@ impl MatchState {
 /// the tracker stops being active when the root match is popped from the match
 /// stack. At this point the [`InvocationTracker::is_fully_processed`] flag is
 /// set.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 struct InvocationTracker {
     // Root of the authorized invocation tree.
     // The authorized invocation tree only contains the contract invocations
@@ -436,8 +434,7 @@ struct InvocationTracker {
 // pre-authorized invocations can happen on behalf of the `address`.
 // In the recording mode this will record the invocations that are authorized
 // on behalf of the address.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct AccountAuthorizationTracker {
     // Tracked address.
     address: AddressObject,
@@ -468,37 +465,32 @@ pub(crate) struct AccountAuthorizationTrackerSnapshot {
 }
 
 // Stores all the authorizations performed by contracts at runtime.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct InvokerContractAuthorizationTracker {
     contract_address: AddressObject,
     invocation_tracker: InvocationTracker,
 }
 
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) enum AuthStackFrame {
     Contract(ContractInvocation),
     CreateContractHostFn(CreateContractArgs),
 }
 
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct ContractInvocation {
     pub(crate) contract_address: AddressObject,
     pub(crate) function_name: Symbol,
 }
 
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct ContractFunction {
     pub(crate) contract_address: AddressObject,
     pub(crate) function_name: Symbol,
     pub(crate) args: Vec<Val>,
 }
 
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) enum AuthorizedFunction {
     ContractFn(ContractFunction),
     CreateContractHostFn(CreateContractArgs),
@@ -506,8 +498,7 @@ pub(crate) enum AuthorizedFunction {
 
 // A single node in the authorized invocation tree.
 // This represents an invocation and all it's authorized sub-invocations.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct AuthorizedInvocation {
     pub(crate) function: AuthorizedFunction,
     pub(crate) sub_invocations: Vec<AuthorizedInvocation>,
@@ -1459,7 +1450,7 @@ impl AuthorizationManager {
 }
 
 // Some helper extensions to support test-observation.
-#[cfg(all(test, not(feature = "next"), feature = "testutils"))]
+#[allow(dead_code)]
 impl AuthorizationManager {
     pub(crate) fn stack_size(&self) -> usize {
         if let Ok(call_stack) = self.call_stack.try_borrow() {
@@ -1469,39 +1460,40 @@ impl AuthorizationManager {
         }
     }
 
-    pub(crate) fn stack_hash(&self) -> u64 {
-        use std::collections::hash_map::DefaultHasher;
-        use std::hash::{Hash, Hasher};
+    pub(crate) fn stack_hash(&self, budget: &Budget) -> Result<u64, HostError> {
+        use std::hash::Hasher;
         if let Ok(call_stack) = self.call_stack.try_borrow() {
-            let mut state = DefaultHasher::new();
-            call_stack.hash(&mut state);
-            state.finish()
+            let mut state = CountingHasher::default();
+            call_stack.metered_hash(&mut state, budget)?;
+            Ok(state.finish())
         } else {
-            0
+            Ok(0)
         }
     }
 
-    pub(crate) fn trackers_hash_and_size(&self) -> (u64, usize) {
-        use std::collections::hash_map::DefaultHasher;
-        use std::hash::{Hash, Hasher};
+    pub(crate) fn trackers_hash_and_size(
+        &self,
+        budget: &Budget,
+    ) -> Result<(u64, usize), HostError> {
+        use std::hash::Hasher;
         let mut size: usize = 0;
-        let mut state = DefaultHasher::new();
-        self.mode.hash(&mut state);
+        let mut state = CountingHasher::default();
+        self.mode.metered_hash(&mut state, budget)?;
         if let Ok(account_trackers) = self.account_trackers.try_borrow() {
             for tracker in account_trackers.iter() {
                 if let Ok(tracker) = tracker.try_borrow() {
                     size = size.saturating_add(1);
-                    tracker.hash(&mut state);
+                    tracker.metered_hash(&mut state, budget)?;
                 }
             }
         }
         if let Ok(invoker_contract_trackers) = self.invoker_contract_trackers.try_borrow() {
             for tracker in invoker_contract_trackers.iter() {
                 size = size.saturating_add(1);
-                tracker.hash(&mut state);
+                tracker.metered_hash(&mut state, budget)?;
             }
         }
-        (state.finish(), size)
+        Ok((state.finish(), size))
     }
 }
 
diff --git a/soroban-env-host/src/budget.rs b/soroban-env-host/src/budget.rs
index 116a9c22..836e9006 100644
--- a/soroban-env-host/src/budget.rs
+++ b/soroban-env-host/src/budget.rs
@@ -778,6 +778,12 @@ impl Budget {
         Ok(())
     }
 
+    pub(crate) fn ensure_shadow_cpu_limit_factor(&self, factor: u64) -> Result<(), HostError> {
+        let mut b = self.0.try_borrow_mut_or_err()?;
+        b.cpu_insns.shadow_limit = b.cpu_insns.limit.saturating_mul(factor);
+        Ok(())
+    }
+
     pub fn get_tracker(&self, ty: ContractCostType) -> Result<CostTracker, HostError> {
         self.0
             .try_borrow_or_err()?
diff --git a/soroban-env-host/src/events/internal.rs b/soroban-env-host/src/events/internal.rs
index f119d42a..e6e2cf2c 100644
--- a/soroban-env-host/src/events/internal.rs
+++ b/soroban-env-host/src/events/internal.rs
@@ -60,7 +60,6 @@ pub(crate) struct InternalDiagnosticEvent {
     pub args: Vec<InternalDiagnosticArg>,
 }
 
-#[cfg(feature = "testutils")]
 impl std::hash::Hash for InternalEvent {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         match self {
@@ -81,7 +80,6 @@ impl std::hash::Hash for InternalEvent {
     }
 }
 
-#[cfg(feature = "testutils")]
 impl std::hash::Hash for EventError {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         match self {
diff --git a/soroban-env-host/src/host.rs b/soroban-env-host/src/host.rs
index c67d3f58..0fdb4f27 100644
--- a/soroban-env-host/src/host.rs
+++ b/soroban-env-host/src/host.rs
@@ -33,15 +33,18 @@ pub(crate) mod ledger_info_helper;
 mod lifecycle;
 mod mem_helper;
 pub(crate) mod metered_clone;
+pub(crate) mod metered_hash;
 pub(crate) mod metered_map;
 pub(crate) mod metered_vector;
 pub(crate) mod metered_xdr;
 mod num;
 mod prng;
+pub(crate) mod trace;
 mod validity;
 
 pub use error::HostError;
 pub use prng::{Seed, SEED_BYTES};
+pub use trace::{TraceEvent, TraceHook, TraceRecord, TraceState};
 
 use self::{
     frame::{Context, ContractReentryMode},
@@ -70,19 +73,6 @@ pub struct LedgerInfo {
     pub max_entry_ttl: u32,
 }
 
-#[cfg(any(test, feature = "testutils"))]
-#[allow(dead_code)]
-pub(crate) enum HostLifecycleEvent<'a> {
-    PushCtx(&'a Context),
-    PopCtx(&'a Context, &'a Result<Val, HostError>),
-    EnvCall(&'static str, &'a [String]),
-    EnvRet(&'static str, &'a Result<String, String>),
-}
-
-#[cfg(any(test, feature = "testutils"))]
-pub(crate) type HostLifecycleHook =
-    Rc<dyn for<'a> Fn(&'a Host, HostLifecycleEvent<'a>) -> Result<(), HostError>>;
-
 #[cfg(any(test, feature = "testutils"))]
 #[derive(Clone, Copy)]
 pub enum ContractInvocationEvent {
@@ -151,8 +141,7 @@ struct HostImpl {
     // the host's execution. No guarantees are made about the stability of this
     // interface, it exists strictly for internal testing of the host.
     #[doc(hidden)]
-    #[cfg(any(test, feature = "testutils"))]
-    lifecycle_event_hook: RefCell<Option<HostLifecycleHook>>,
+    trace_hook: RefCell<Option<TraceHook>>,
     // Store a simple contract invocation hook for public usage.
     // The hook triggers when the top-level contract invocation
     // starts and when it ends.
@@ -283,12 +272,11 @@ impl_checked_borrow_helpers!(
     try_borrow_previous_authorization_manager_mut
 );
 
-#[cfg(any(test, feature = "testutils"))]
 impl_checked_borrow_helpers!(
-    lifecycle_event_hook,
-    Option<HostLifecycleHook>,
-    try_borrow_lifecycle_event_hook,
-    try_borrow_lifecycle_event_hook_mut
+    trace_hook,
+    Option<TraceHook>,
+    try_borrow_trace_hook,
+    try_borrow_trace_hook_mut
 );
 
 #[cfg(any(test, feature = "testutils"))]
@@ -347,8 +335,7 @@ impl Host {
             contracts: Default::default(),
             #[cfg(any(test, feature = "testutils"))]
             previous_authorization_manager: RefCell::new(None),
-            #[cfg(any(test, feature = "testutils"))]
-            lifecycle_event_hook: RefCell::new(None),
+            trace_hook: RefCell::new(None),
             #[cfg(any(test, feature = "testutils"))]
             top_contract_invocation_hook: RefCell::new(None),
             #[cfg(any(test, feature = "testutils"))]
@@ -581,38 +568,25 @@ impl Host {
     }
 }
 
-#[cfg(feature = "testutils")]
-macro_rules! call_env_call_hook {
+macro_rules! call_trace_env_call {
     ($self:expr, $($arg:expr),*) => {
-        #[cfg(feature="testutils")]
+        if $self.tracing_enabled()
         {
-            $self.env_call_hook(function_short_name!(), &[$(format!("{:?}", $arg)),*])?;
+            $self.trace_env_call(function_short_name!(), &[$(&$arg),*])?;
         }
     };
 }
 
-#[cfg(not(feature = "testutils"))]
-macro_rules! call_env_call_hook {
-    ($self:expr, $($arg:expr),*) => {};
-}
-
-#[cfg(feature = "testutils")]
-macro_rules! call_env_ret_hook {
-    ($self:expr, $arg:expr) => {
-        #[cfg(feature = "testutils")]
-        {
-            let res_str: Result<String, &HostError> = match &$arg {
-                Ok(ok) => Ok(format!("{:?}", ok)),
+macro_rules! call_trace_env_ret {
+    ($self:expr, $arg:expr) => {{
+        if $self.tracing_enabled() {
+            let dyn_res: Result<&dyn core::fmt::Debug, &HostError> = match &$arg {
+                Ok(ref ok) => Ok(ok),
                 Err(err) => Err(err),
             };
-            $self.env_ret_hook(function_short_name!(), &res_str)?;
+            $self.trace_env_ret(function_short_name!(), &dyn_res)?;
         }
-    };
-}
-
-#[cfg(not(feature = "testutils"))]
-macro_rules! call_env_ret_hook {
-    ($self:expr, $arg:expr) => {};
+    }};
 }
 
 // Notes on metering: these are called from the guest and thus charged on the VM instructions.
@@ -712,22 +686,23 @@ impl EnvBase for Host {
         x
     }
 
-    #[cfg(feature = "testutils")]
-    fn env_call_hook(&self, fname: &'static str, args: &[String]) -> Result<(), HostError> {
-        self.call_any_lifecycle_hook(HostLifecycleEvent::EnvCall(fname, args))
+    fn tracing_enabled(&self) -> bool {
+        match self.try_borrow_trace_hook() {
+            Ok(hook) => hook.is_some(),
+            Err(_) => false,
+        }
     }
 
-    #[cfg(feature = "testutils")]
-    fn env_ret_hook(
+    fn trace_env_call(&self, fname: &'static str, args: &[&dyn Debug]) -> Result<(), HostError> {
+        self.call_any_lifecycle_hook(TraceEvent::EnvCall(fname, args))
+    }
+
+    fn trace_env_ret(
         &self,
         fname: &'static str,
-        res: &Result<String, &HostError>,
+        res: &Result<&dyn Debug, &HostError>,
     ) -> Result<(), HostError> {
-        // We have to defer error formatting to here because the Env type does
-        // not know enough about the structure of errors (in particular that we
-        // do _not_ want to format debuginfo into the lifecycle-hook string).
-        let res = res.clone().map_err(|he| format!("{:?}", he.error));
-        self.call_any_lifecycle_hook(HostLifecycleEvent::EnvRet(fname, &res))
+        self.call_any_lifecycle_hook(TraceEvent::EnvRet(fname, res))
     }
 
     fn check_same_env(&self, other: &Self) -> Result<(), Self::Error> {
@@ -749,9 +724,9 @@ impl EnvBase for Host {
         b_pos: U32Val,
         slice: &[u8],
     ) -> Result<BytesObject, HostError> {
-        call_env_call_hook!(self, b, b_pos, slice.len());
+        call_trace_env_call!(self, b, b_pos, slice.len());
         let res = self.memobj_copy_from_slice::<ScBytes>(b, b_pos, slice);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
@@ -761,9 +736,9 @@ impl EnvBase for Host {
         b_pos: U32Val,
         slice: &mut [u8],
     ) -> Result<(), HostError> {
-        call_env_call_hook!(self, b, b_pos, slice.len());
+        call_trace_env_call!(self, b, b_pos, slice.len());
         let res = self.memobj_copy_to_slice::<ScBytes>(b, b_pos, slice);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
@@ -773,9 +748,9 @@ impl EnvBase for Host {
         b_pos: U32Val,
         slice: &mut [u8],
     ) -> Result<(), HostError> {
-        call_env_call_hook!(self, b, b_pos, slice.len());
+        call_trace_env_call!(self, b, b_pos, slice.len());
         let res = self.memobj_copy_to_slice::<ScString>(b, b_pos, slice);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
@@ -785,28 +760,28 @@ impl EnvBase for Host {
         b_pos: U32Val,
         slice: &mut [u8],
     ) -> Result<(), HostError> {
-        call_env_call_hook!(self, s, b_pos, slice.len());
+        call_trace_env_call!(self, s, b_pos, slice.len());
         let res = self.memobj_copy_to_slice::<ScSymbol>(s, b_pos, slice);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn bytes_new_from_slice(&self, mem: &[u8]) -> Result<BytesObject, HostError> {
-        call_env_call_hook!(self, mem.len());
+        call_trace_env_call!(self, mem.len());
         let res = self.add_host_object(self.scbytes_from_slice(mem)?);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn string_new_from_slice(&self, s: &[u8]) -> Result<StringObject, HostError> {
-        call_env_call_hook!(self, s.len());
+        call_trace_env_call!(self, s.len());
         let res = self.add_host_object(ScString(self.metered_slice_to_vec(s)?.try_into()?));
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn symbol_new_from_slice(&self, s: &[u8]) -> Result<SymbolObject, HostError> {
-        call_env_call_hook!(self, s.len());
+        call_trace_env_call!(self, s.len());
         // Note: this whole function could be replaced by `ScSymbol::try_from_bytes`
         // in order to avoid duplication. It is duplicated in order to support
         // a slightly different check order for the sake of observation consistency.
@@ -822,12 +797,12 @@ impl EnvBase for Host {
             })?;
         }
         let res = self.add_host_object(ScSymbol(self.metered_slice_to_vec(s)?.try_into()?));
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn map_new_from_slices(&self, keys: &[&str], vals: &[Val]) -> Result<MapObject, HostError> {
-        call_env_call_hook!(self, keys.len());
+        call_trace_env_call!(self, keys.len());
         if keys.len() != vals.len() {
             return Err(self.err(
                 ScErrorType::Object,
@@ -848,7 +823,7 @@ impl EnvBase for Host {
             .collect::<Result<Vec<(Val, Val)>, HostError>>()?;
         let map = HostMap::from_map(map_vec, self)?;
         let res = self.add_host_object(map);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
@@ -858,7 +833,7 @@ impl EnvBase for Host {
         keys: &[&str],
         vals: &mut [Val],
     ) -> Result<Void, HostError> {
-        call_env_call_hook!(self, map, keys.len());
+        call_trace_env_call!(self, map, keys.len());
         if keys.len() != vals.len() {
             return Err(self.err(
                 ScErrorType::Object,
@@ -889,23 +864,23 @@ impl EnvBase for Host {
             Ok(())
         })?;
         let res = Ok(Val::VOID);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn vec_new_from_slice(&self, vals: &[Val]) -> Result<VecObject, Self::Error> {
-        call_env_call_hook!(self, vals.len());
+        call_trace_env_call!(self, vals.len());
         let vec = HostVec::from_exact_iter(vals.iter().cloned(), self.budget_ref())?;
         for v in vec.iter() {
             self.check_val_integrity(*v)?;
         }
         let res = self.add_host_object(vec);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn vec_unpack_to_slice(&self, vec: VecObject, vals: &mut [Val]) -> Result<Void, Self::Error> {
-        call_env_call_hook!(self, vec, vals.len());
+        call_trace_env_call!(self, vec, vals.len());
         self.visit_obj(vec, |hv: &HostVec| {
             if hv.len() != vals.len() {
                 return Err(self.err(
@@ -920,12 +895,12 @@ impl EnvBase for Host {
             Ok(())
         })?;
         let res = Ok(Val::VOID);
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn symbol_index_in_strs(&self, sym: Symbol, slices: &[&str]) -> Result<U32Val, Self::Error> {
-        call_env_call_hook!(self, sym, slices.len());
+        call_trace_env_call!(self, sym, slices.len());
         let mut found = None;
         for (i, slice) in slices.iter().enumerate() {
             if self.symbol_matches(slice.as_bytes(), sym)? && found.is_none() {
@@ -941,15 +916,15 @@ impl EnvBase for Host {
             )),
             Some(idx) => Ok(U32Val::from(self.usize_to_u32(idx)?)),
         };
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 
     fn log_from_slice(&self, msg: &str, vals: &[Val]) -> Result<Void, HostError> {
-        call_env_call_hook!(self, msg.len(), vals.len());
+        call_trace_env_call!(self, msg.len(), vals.len());
         self.log_diagnostics(msg, vals);
         let res = Ok(Void::from(()));
-        call_env_ret_hook!(self, res);
+        call_trace_env_ret!(self, res);
         res
     }
 }
@@ -2964,23 +2939,19 @@ impl Host {
     {
         f(self.0.budget.clone())
     }
+}
 
-    #[cfg(any(test, feature = "testutils"))]
+impl Host {
     #[allow(dead_code)]
-    pub(crate) fn set_lifecycle_event_hook(
-        &self,
-        hook: Option<HostLifecycleHook>,
-    ) -> Result<(), HostError> {
-        *self.try_borrow_lifecycle_event_hook_mut()? = hook;
+    pub(crate) fn set_trace_hook(&self, hook: Option<TraceHook>) -> Result<(), HostError> {
+        self.call_any_lifecycle_hook(TraceEvent::End)?;
+        *self.try_borrow_trace_hook_mut()? = hook;
+        self.call_any_lifecycle_hook(TraceEvent::Begin)?;
         Ok(())
     }
 
-    #[cfg(feature = "testutils")]
-    pub(crate) fn call_any_lifecycle_hook(
-        &self,
-        event: HostLifecycleEvent,
-    ) -> Result<(), HostError> {
-        match &*self.try_borrow_lifecycle_event_hook()? {
+    pub(crate) fn call_any_lifecycle_hook(&self, event: TraceEvent) -> Result<(), HostError> {
+        match &*self.try_borrow_trace_hook()? {
             Some(hook) => hook(self, event),
             None => Ok(()),
         }
diff --git a/soroban-env-host/src/host/frame.rs b/soroban-env-host/src/host/frame.rs
index d5650080..0af46bb0 100644
--- a/soroban-env-host/src/host/frame.rs
+++ b/soroban-env-host/src/host/frame.rs
@@ -61,7 +61,7 @@ pub(crate) struct TestContractFrame {
     pub(crate) instance: ScContractInstance,
 }
 
-#[cfg(feature = "testutils")]
+#[cfg(any(test, feature = "testutils"))]
 impl std::hash::Hash for TestContractFrame {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         self.id.hash(state);
@@ -89,8 +89,7 @@ impl TestContractFrame {
 
 /// Context pairs a variable-case [`Frame`] enum with state that's common to all
 /// cases (eg. a [`Prng`]).
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct Context {
     pub(crate) frame: Frame,
     pub(crate) prng: Option<Prng>,
@@ -108,8 +107,7 @@ pub(crate) struct Context {
 /// Frames are also the units of (sub-)transactions: each frame captures
 /// the host state when it is pushed, and the [`FrameGuard`] will either
 /// commit or roll back that state when it pops the stack.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) enum Frame {
     ContractVM {
         vm: Rc<Vm>,
@@ -406,12 +404,11 @@ impl Host {
             storage: None,
         };
         let rp = self.push_context(ctx)?;
-        #[cfg(feature = "testutils")]
         {
             // We do this _after_ the context is pushed, in order to let the
             // observation code assume a context exists
             if let Some(ctx) = self.try_borrow_context_stack()?.last() {
-                self.call_any_lifecycle_hook(crate::host::HostLifecycleEvent::PushCtx(ctx))?;
+                self.call_any_lifecycle_hook(crate::host::TraceEvent::PushCtx(ctx))?;
             }
         }
         #[cfg(any(test, feature = "testutils"))]
@@ -506,12 +503,15 @@ impl Host {
                 res = Err(e)
             }
         }
-        #[cfg(feature = "testutils")]
         {
             // We do this _before_ the context is popped, in order to let the
             // observation code assume a context exists
             if let Some(ctx) = self.try_borrow_context_stack()?.last() {
-                self.call_any_lifecycle_hook(crate::host::HostLifecycleEvent::PopCtx(&ctx, &res))?;
+                let res = match &res {
+                    Ok(v) => Ok(*v),
+                    Err(ref e) => Err(e),
+                };
+                self.call_any_lifecycle_hook(crate::host::TraceEvent::PopCtx(&ctx, &res))?;
             }
         }
         if res.is_err() {
diff --git a/soroban-env-host/src/host/metered_hash.rs b/soroban-env-host/src/host/metered_hash.rs
new file mode 100644
index 00000000..1d8b5396
--- /dev/null
+++ b/soroban-env-host/src/host/metered_hash.rs
@@ -0,0 +1,43 @@
+use crate::{budget::Budget, xdr::ContractCostType, HostError};
+use std::hash::{Hash, Hasher};
+
+// Technically we should be metering the cost of the hash function used, but
+// this codepath is used only for charging the costs of tracing against the
+// shadow budget, and we do not want to add a cost type to the protocol just
+// for this purpose (it's not protocol-visible at all).
+//
+// In practice, Rust's default hasher is SIP-1-3 which is of a similar order
+// of magnitude as a ChaCha20 round, so this is a reasonable approximation.
+// It's also fine if we overcharge here, since again this is only used to
+// ensure that if the hashing code is ever called _outside_ the shadow budget
+// it's not a free operation / DoS vector.
+const HASH_COST_TYPE: ContractCostType = ContractCostType::ChaCha20DrawBytes;
+
+#[derive(Default)]
+pub struct CountingHasher {
+    count: usize,
+    hasher: std::collections::hash_map::DefaultHasher,
+}
+
+impl Hasher for CountingHasher {
+    fn finish(&self) -> u64 {
+        self.hasher.finish()
+    }
+
+    fn write(&mut self, bytes: &[u8]) {
+        self.count = self.count.saturating_add(bytes.len());
+        self.hasher.write(bytes);
+    }
+}
+
+pub(crate) trait MeteredHash {
+    fn metered_hash(&self, hasher: &mut CountingHasher, budget: &Budget) -> Result<(), HostError>;
+}
+
+impl<T: Hash> MeteredHash for T {
+    fn metered_hash(&self, hasher: &mut CountingHasher, budget: &Budget) -> Result<(), HostError> {
+        self.hash(hasher);
+        budget.charge(HASH_COST_TYPE, Some(hasher.count as u64))?;
+        Ok(())
+    }
+}
diff --git a/soroban-env-host/src/host/metered_map.rs b/soroban-env-host/src/host/metered_map.rs
index ede13ba1..d3bb1316 100644
--- a/soroban-env-host/src/host/metered_map.rs
+++ b/soroban-env-host/src/host/metered_map.rs
@@ -30,6 +30,16 @@ where
     }
 }
 
+impl<K, V, Ctx> std::hash::Hash for MeteredOrdMap<K, V, Ctx>
+where
+    K: std::hash::Hash,
+    V: std::hash::Hash,
+{
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.map.hash(state);
+    }
+}
+
 impl<K, V, Ctx> MeteredOrdMap<K, V, Ctx>
 where
     K: DeclaredSizeForMetering,
diff --git a/soroban-env-host/src/host/metered_vector.rs b/soroban-env-host/src/host/metered_vector.rs
index 293ba3d4..e492dce7 100644
--- a/soroban-env-host/src/host/metered_vector.rs
+++ b/soroban-env-host/src/host/metered_vector.rs
@@ -22,6 +22,15 @@ impl<A> Default for MeteredVector<A> {
     }
 }
 
+impl<T> std::hash::Hash for MeteredVector<T>
+where
+    T: std::hash::Hash,
+{
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.vec.hash(state);
+    }
+}
+
 impl<A> MeteredVector<A>
 where
     A: DeclaredSizeForMetering,
diff --git a/soroban-env-host/src/host/prng.rs b/soroban-env-host/src/host/prng.rs
index 42fc21a8..dc0843e2 100644
--- a/soroban-env-host/src/host/prng.rs
+++ b/soroban-env-host/src/host/prng.rs
@@ -85,7 +85,6 @@ pub type Seed = <rand_chacha::ChaCha20Rng as rand::SeedableRng>::Seed;
 pub const SEED_BYTES: u64 = <Seed as DeclaredSizeForMetering>::DECLARED_SIZE;
 static_assertions::const_assert_eq!(SEED_BYTES, 32);
 
-#[cfg(any(feature = "testutils", test))]
 impl std::hash::Hash for Prng {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         self.0.get_seed().hash(state);
diff --git a/soroban-env-host/src/host/trace.rs b/soroban-env-host/src/host/trace.rs
new file mode 100644
index 00000000..ce114147
--- /dev/null
+++ b/soroban-env-host/src/host/trace.rs
@@ -0,0 +1,352 @@
+#![allow(dead_code)]
+use crate::{
+    budget::AsBudget,
+    events::InternalEvent,
+    host::Frame,
+    host::{
+        metered_hash::{CountingHasher, MeteredHash},
+        Context,
+    },
+    Host, HostError, Val,
+};
+use std::{fmt::Debug, hash::Hasher, rc::Rc};
+
+// Formatting TraceEvents and TraceStates is done in a submodule.
+mod fmt;
+
+// When capturing a TraceState, we want to ensure that the shadow budget is much higher
+// than normal; not so high that it will run forever but high enough that we can manage
+// to actually record the quantity of detail that tracing records (eg. hashing everything
+// in the host on every host function call and return!).
+const TRACE_STATE_SHADOW_CPU_LIMIT_FACTOR: u64 = 500;
+
+pub type TraceHook = Rc<dyn for<'a> Fn(&'a Host, TraceEvent<'a>) -> Result<(), HostError>>;
+
+pub enum TraceEvent<'a> {
+    Begin,
+    #[allow(private_interfaces)]
+    PushCtx(&'a Context),
+    #[allow(private_interfaces)]
+    PopCtx(&'a Context, &'a Result<Val, &'a HostError>),
+    EnvCall(&'static str, &'a [&'a dyn Debug]),
+    EnvRet(&'static str, &'a Result<&'a dyn Debug, &'a HostError>),
+    End,
+}
+
+/// A TraceRecord is simply a pair of a [TraceEvent] and a [TraceState] with a
+/// convenience constructor that tries to build the [TraceState] and returns the
+/// [TraceEvent] in a `Result::Err` if it fails (eg. if the shadow budget is
+/// exhausted).
+pub struct TraceRecord<'a> {
+    pub event: TraceEvent<'a>,
+    pub state: TraceState,
+}
+
+impl<'a> TraceRecord<'a> {
+    pub fn new(host: &Host, event: TraceEvent<'a>) -> Result<Self, TraceEvent<'a>> {
+        match TraceState::new(host) {
+            Some(state) => Ok(Self { event, state }),
+            None => Err(event),
+        }
+    }
+}
+
+/// TraceState holds a summary of the state of a Soroban host at a particular moment.
+/// It is relatively expensive to capture, and is captured under the shadow budget.
+/// If the shadow budget is exhausted, capturing the TraceState will fail (which is
+/// why its constructor returns an `Option`).
+pub struct TraceState {
+    cpu_insns: u64,
+    mem_bytes: u64,
+    local_prng_hash: u64,
+    base_prng_hash: u64,
+    local_objs_size: usize,
+    local_objs_hash: u64,
+    global_objs_size: usize,
+    global_objs_hash: u64,
+    vm_mem_size: usize,
+    vm_mem_hash: u64,
+    vm_exports_size: usize,
+    vm_exports_hash: u64,
+    events_size: usize,
+    events_hash: u64,
+    instance_storage_size: usize,
+    instance_storage_hash: u64,
+    ledger_storage_size: usize,
+    ledger_storage_hash: u64,
+    storage_footprint_size: usize,
+    storage_footprint_hash: u64,
+    auth_stack_size: usize,
+    auth_stack_hash: u64,
+    auth_trackers_size: usize,
+    auth_trackers_hash: u64,
+    context_stack_size: usize,
+    context_stack_hash: u64,
+}
+
+impl TraceState {
+    pub fn new(host: &Host) -> Option<Self> {
+        let budget = host.budget_ref();
+        if budget
+            .ensure_shadow_cpu_limit_factor(TRACE_STATE_SHADOW_CPU_LIMIT_FACTOR)
+            .is_err()
+        {
+            return None;
+        }
+        let mut state = None;
+        budget.with_shadow_mode(|| {
+            let (vm_mem_hash, vm_mem_size) = host.memory_hash_and_size();
+            let (vm_exports_hash, vm_exports_size) = host.vm_exports_hash_and_size();
+            let (auth_trackers_hash, auth_trackers_size) = host.auth_trackers_hash_and_size();
+            state = Some(TraceState {
+                cpu_insns: host.as_budget().get_cpu_insns_consumed()?,
+                mem_bytes: host.as_budget().get_mem_bytes_consumed()?,
+                local_prng_hash: host.local_prng_hash(),
+                base_prng_hash: host.base_prng_hash(),
+                local_objs_size: host.local_objs_size(),
+                local_objs_hash: host.local_objs_hash(),
+                global_objs_size: host.global_objs_size(),
+                global_objs_hash: host.global_objs_hash(),
+                vm_mem_size,
+                vm_mem_hash,
+                vm_exports_size,
+                vm_exports_hash,
+                events_size: host.events_size(),
+                events_hash: host.events_hash(),
+                instance_storage_size: host.instance_storage_size(),
+                instance_storage_hash: host.instance_storage_hash(),
+                ledger_storage_size: host.ledger_storage_size(),
+                ledger_storage_hash: host.ledger_storage_hash(),
+                storage_footprint_size: host.storage_footprint_size(),
+                storage_footprint_hash: host.storage_footprint_hash(),
+                context_stack_size: host.context_stack_size(),
+                context_stack_hash: host.context_stack_hash(),
+                auth_stack_size: host.auth_stack_size(),
+                auth_stack_hash: host.auth_stack_hash(),
+                auth_trackers_size,
+                auth_trackers_hash,
+            });
+            Ok(())
+        });
+        state
+    }
+}
+
+impl Host {
+    fn hash_one<H: MeteredHash>(&self, h: &H) -> u64 {
+        let mut state = CountingHasher::default();
+        self.budget_ref()
+            .with_shadow_mode(|| h.metered_hash(&mut state, self.budget_ref()));
+        state.finish()
+    }
+
+    fn hash_iter<H: MeteredHash>(&self, h: impl Iterator<Item = H>) -> u64 {
+        let mut state = CountingHasher::default();
+        self.budget_ref().with_shadow_mode(|| {
+            for i in h {
+                i.metered_hash(&mut state, self.budget_ref())?;
+            }
+            Ok(())
+        });
+        state.finish()
+    }
+
+    fn hash_optional<H: MeteredHash>(&self, h: &Option<H>) -> u64 {
+        if let Some(x) = h {
+            self.hash_one(x)
+        } else {
+            0
+        }
+    }
+
+    fn base_prng_hash(&self) -> u64 {
+        if let Ok(prng) = self.try_borrow_base_prng() {
+            self.hash_optional(&*prng)
+        } else {
+            0
+        }
+    }
+    fn local_prng_hash(&self) -> u64 {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                return self.hash_optional(&ctx.prng);
+            }
+        }
+        0
+    }
+    fn local_objs_size(&self) -> usize {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Frame::ContractVM {
+                    relative_objects, ..
+                } = &ctx.frame
+                {
+                    return relative_objects.len();
+                }
+            }
+        }
+        0
+    }
+    fn local_objs_hash(&self) -> u64 {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Frame::ContractVM {
+                    relative_objects, ..
+                } = &ctx.frame
+                {
+                    return self
+                        .hash_iter(relative_objects.iter().map(|x| x.to_val().get_payload()));
+                }
+            }
+        }
+        0
+    }
+    fn global_objs_size(&self) -> usize {
+        if let Ok(objs) = self.try_borrow_objects() {
+            objs.len()
+        } else {
+            0
+        }
+    }
+    fn global_objs_hash(&self) -> u64 {
+        if let Ok(objs) = self.try_borrow_objects() {
+            self.hash_iter(objs.iter())
+        } else {
+            0
+        }
+    }
+    fn memory_hash_and_size(&self) -> (u64, usize) {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Frame::ContractVM { vm, .. } = &ctx.frame {
+                    if let Ok(pair) = vm.memory_hash_and_size(self.budget_ref()) {
+                        return pair;
+                    }
+                }
+            }
+        }
+        (0, 0)
+    }
+    fn events_size(&self) -> usize {
+        if let Ok(evts) = self.try_borrow_events() {
+            evts.vec
+                .iter()
+                .filter(|(e, _)| match e {
+                    InternalEvent::Contract(_) => true,
+                    InternalEvent::Diagnostic(_) => false,
+                })
+                .count()
+        } else {
+            0
+        }
+    }
+    fn events_hash(&self) -> u64 {
+        if let Ok(evts) = self.try_borrow_events() {
+            self.hash_iter(evts.vec.iter().filter(|(e, _)| match e {
+                InternalEvent::Contract(_) => true,
+                InternalEvent::Diagnostic(_) => false,
+            }))
+        } else {
+            0
+        }
+    }
+    fn instance_storage_size(&self) -> usize {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Some(storage) = &ctx.storage {
+                    return storage.map.len();
+                }
+            }
+        }
+        0
+    }
+    fn instance_storage_hash(&self) -> u64 {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Some(storage) = &ctx.storage {
+                    return self.hash_one(&storage.map);
+                }
+            }
+        }
+        0
+    }
+    fn ledger_storage_size(&self) -> usize {
+        if let Ok(store) = self.try_borrow_storage() {
+            store.map.len()
+        } else {
+            0
+        }
+    }
+    fn ledger_storage_hash(&self) -> u64 {
+        if let Ok(store) = self.try_borrow_storage() {
+            self.hash_one(&store.map)
+        } else {
+            0
+        }
+    }
+
+    fn auth_stack_size(&self) -> usize {
+        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
+            auth_mgr.stack_size()
+        } else {
+            0
+        }
+    }
+    fn auth_stack_hash(&self) -> u64 {
+        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
+            if let Ok(hash) = auth_mgr.stack_hash(self.budget_ref()) {
+                return hash;
+            }
+        }
+        0
+    }
+    fn auth_trackers_hash_and_size(&self) -> (u64, usize) {
+        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
+            if let Ok(pair) = auth_mgr.trackers_hash_and_size(self.budget_ref()) {
+                return pair;
+            }
+        }
+        (0, 0)
+    }
+    fn context_stack_size(&self) -> usize {
+        if let Ok(frames) = self.try_borrow_context_stack() {
+            frames.len()
+        } else {
+            0
+        }
+    }
+    fn context_stack_hash(&self) -> u64 {
+        if let Ok(frames) = self.try_borrow_context_stack() {
+            self.hash_iter(frames.iter())
+        } else {
+            0
+        }
+    }
+    fn storage_footprint_size(&self) -> usize {
+        if let Ok(storage) = self.try_borrow_storage() {
+            storage.footprint.0.len()
+        } else {
+            0
+        }
+    }
+    fn storage_footprint_hash(&self) -> u64 {
+        if let Ok(storage) = self.try_borrow_storage() {
+            self.hash_one(&storage.footprint.0)
+        } else {
+            0
+        }
+    }
+
+    fn vm_exports_hash_and_size(&self) -> (u64, usize) {
+        if let Ok(ctxs) = self.try_borrow_context_stack() {
+            if let Some(ctx) = ctxs.last() {
+                if let Frame::ContractVM { vm, .. } = &ctx.frame {
+                    if let Ok(pair) = vm.exports_hash_and_size(self.budget_ref()) {
+                        return pair;
+                    }
+                }
+            }
+        }
+        (0, 0)
+    }
+}
diff --git a/soroban-env-host/src/host/trace/fmt.rs b/soroban-env-host/src/host/trace/fmt.rs
new file mode 100644
index 00000000..da424327
--- /dev/null
+++ b/soroban-env-host/src/host/trace/fmt.rs
@@ -0,0 +1,238 @@
+use super::{TraceEvent, TraceState};
+use crate::{host::Frame, xdr::ContractExecutable, Symbol, SymbolObject, SymbolSmall, Val};
+use core::fmt::{Debug, Display};
+
+impl<'a> Debug for TraceEvent<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TraceEvent::Begin => write!(f, "TraceEvent::Begin"),
+            TraceEvent::PushCtx(..) => write!(f, "TraceEvent::PushCtx(...)"),
+            TraceEvent::PopCtx(..) => write!(f, "TraceEvent::PopCtx(...)"),
+            TraceEvent::EnvCall(hostfn, args) => {
+                write!(f, "TraceEvent::EnvCall({:?}, {:?})", hostfn, args)
+            }
+            TraceEvent::EnvRet(hostfn, Ok(ok)) => {
+                write!(f, "TraceEvent::EnvRet({:?}, Ok({:?}))", hostfn, ok)
+            }
+            TraceEvent::EnvRet(hostfn, Err(err)) => {
+                write!(f, "TraceEvent::EnvRet({:?}, Err({:?}))", hostfn, err)
+            }
+            TraceEvent::End => write!(f, "TraceEvent::End"),
+        }
+    }
+}
+
+enum ShortHashOrStaticStr {
+    ShortHash(u32),
+    StaticStr(&'static str),
+}
+
+impl From<&'static str> for ShortHashOrStaticStr {
+    fn from(s: &'static str) -> Self {
+        Self::StaticStr(s)
+    }
+}
+
+impl From<&crate::xdr::Hash> for ShortHashOrStaticStr {
+    fn from(h: &crate::xdr::Hash) -> Self {
+        if let Ok(bytes4) = <[u8; 4]>::try_from(&h.0[0..4]) {
+            Self::ShortHash(u32::from_be_bytes(bytes4))
+        } else {
+            Self::StaticStr("")
+        }
+    }
+}
+
+impl From<&ContractExecutable> for ShortHashOrStaticStr {
+    fn from(exec: &ContractExecutable) -> Self {
+        match exec {
+            ContractExecutable::Wasm(hash) => hash.into(),
+            ContractExecutable::StellarAsset => "SAC".into(),
+        }
+    }
+}
+
+impl Display for ShortHashOrStaticStr {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ShortHashOrStaticStr::ShortHash(hash) => write!(f, "{:4.4x}", hash),
+            ShortHashOrStaticStr::StaticStr(s) => write!(f, "{}", s),
+        }
+    }
+}
+
+struct FrameId {
+    ty: &'static str,
+    id: ShortHashOrStaticStr,
+    sym: Option<Symbol>,
+}
+
+impl Display for FrameId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}:{}", self.ty, self.id)?;
+        if let Some(s) = &self.sym {
+            write!(f, ":")?;
+            if let Ok(small) = SymbolSmall::try_from(*s) {
+                for i in small.into_iter() {
+                    write!(f, "{}", i)?;
+                }
+            } else if let Ok(obj) = SymbolObject::try_from(*s) {
+                write!(f, "sym#{}", obj.get_handle())?
+            } else {
+                write!(f, "{:?}", s)?
+            }
+        }
+        Ok(())
+    }
+}
+
+impl<'a> TraceEvent<'a> {
+    pub fn is_begin(&self) -> bool {
+        match self {
+            TraceEvent::Begin => true,
+            _ => false,
+        }
+    }
+
+    pub fn is_end(&self) -> bool {
+        match self {
+            TraceEvent::End => true,
+            _ => false,
+        }
+    }
+
+    fn frame_id_and_args(frame: &Frame) -> (FrameId, &[Val]) {
+        match frame {
+            Frame::ContractVM {
+                fn_name,
+                args,
+                instance,
+                ..
+            } => (
+                FrameId {
+                    ty: "VM",
+                    id: (&instance.executable).into(),
+                    sym: Some(*fn_name),
+                },
+                &args,
+            ),
+            // TODO: this isn't ideal, we should capture the args in Frame::HostFunction
+            Frame::HostFunction(ty) => (
+                FrameId {
+                    ty: ty.name(),
+                    id: "".into(),
+                    sym: None,
+                },
+                &[],
+            ),
+            Frame::StellarAssetContract(id, fn_name, args, _) => (
+                FrameId {
+                    ty: "SAC",
+                    id: id.into(),
+                    sym: Some(*fn_name),
+                },
+                &args,
+            ),
+            #[cfg(any(test, feature = "testutils"))]
+            Frame::TestContract(tc) => (
+                FrameId {
+                    ty: "TEST",
+                    id: (&tc.id).into(),
+                    sym: Some(tc.func),
+                },
+                &tc.args,
+            ),
+        }
+    }
+}
+
+impl<'a> Display for TraceEvent<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TraceEvent::PushCtx(ctx) => {
+                let (frameid, args) = Self::frame_id_and_args(&ctx.frame);
+                write!(f, "push {frameid}(")?;
+                for (i, arg) in args.iter().enumerate() {
+                    if i != 0 {
+                        write!(f, ", ")?;
+                    }
+                    write!(f, "{arg:?}")?;
+                }
+                write!(f, ")")
+            }
+            TraceEvent::PopCtx(ctx, Ok(ok)) => {
+                let (frameid, _) = Self::frame_id_and_args(&ctx.frame);
+                write!(f, "pop {frameid} -> Ok({ok:?})")
+            }
+            TraceEvent::PopCtx(ctx, Err(err)) => {
+                let (frameid, _) = Self::frame_id_and_args(&ctx.frame);
+                write!(f, "pop {frameid} -> Err({:?})", err.error)
+            }
+            TraceEvent::EnvCall(hostfn, args) => {
+                write!(f, "call {hostfn}(")?;
+                for (i, arg) in args.iter().enumerate() {
+                    if i != 0 {
+                        write!(f, ", ")?;
+                    }
+                    write!(f, "{arg:?}")?;
+                }
+                write!(f, ")")
+            }
+            TraceEvent::EnvRet(hostfn, Ok(ok)) => {
+                write!(f, "ret {hostfn} -> Ok({ok:?})")
+            }
+            TraceEvent::EnvRet(hostfn, Err(err)) => {
+                write!(f, "ret {hostfn} -> Err({:?})", err.error)
+            }
+            TraceEvent::Begin => write!(f, "begin"),
+            TraceEvent::End => write!(f, "end"),
+        }
+    }
+}
+
+struct RenderHash(u64);
+impl Display for RenderHash {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.0 == 0 {
+            write!(f, "-")
+        } else {
+            // Truncate the hash to u32, to fit on a line.
+            write!(f, "{:4.4x}", self.0 as u32)
+        }
+    }
+}
+
+struct RenderSizeAndHash(usize, u64);
+impl Display for RenderSizeAndHash {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.0 == 0 {
+            write!(f, "-")
+        } else {
+            write!(f, "{}@{}", self.0, RenderHash(self.1))
+        }
+    }
+}
+
+impl Display for TraceState {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "cpu:{}, mem:{}, prngs:{}/{}, objs:{}/{}, vm:{}/{}, evt:{}, store:{}/{}, foot:{}, stk:{}, auth:{}/{}",
+            self.cpu_insns,
+            self.mem_bytes,
+            RenderHash(self.local_prng_hash),
+            RenderHash(self.base_prng_hash),
+            RenderSizeAndHash(self.local_objs_size, self.local_objs_hash),
+            RenderSizeAndHash(self.global_objs_size, self.global_objs_hash),
+            RenderSizeAndHash(self.vm_mem_size, self.vm_mem_hash),
+            RenderSizeAndHash(self.vm_exports_size, self.vm_exports_hash),
+            RenderSizeAndHash(self.events_size, self.events_hash),
+            RenderSizeAndHash(self.instance_storage_size, self.instance_storage_hash),
+            RenderSizeAndHash(self.ledger_storage_size, self.ledger_storage_hash),
+            RenderSizeAndHash(self.storage_footprint_size, self.storage_footprint_hash),
+            RenderSizeAndHash(self.context_stack_size, self.context_stack_hash),
+            RenderSizeAndHash(self.auth_stack_size, self.auth_stack_hash),
+            RenderSizeAndHash(self.auth_trackers_size, self.auth_trackers_hash),
+        )
+    }
+}
diff --git a/soroban-env-host/src/host_object.rs b/soroban-env-host/src/host_object.rs
index 3c47b20d..41f7a389 100644
--- a/soroban-env-host/src/host_object.rs
+++ b/soroban-env-host/src/host_object.rs
@@ -18,29 +18,7 @@ use crate::{
 pub(crate) type HostMap = MeteredOrdMap<Val, Val, Host>;
 pub(crate) type HostVec = MeteredVector<Val>;
 
-#[cfg(feature = "testutils")]
-impl std::hash::Hash for HostVec {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.vec.len().hash(state);
-        for x in self.vec.iter() {
-            x.get_payload().hash(state);
-        }
-    }
-}
-
-#[cfg(feature = "testutils")]
-impl std::hash::Hash for HostMap {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.len().hash(state);
-        for (k, v) in self.map.iter() {
-            k.get_payload().hash(state);
-            v.get_payload().hash(state);
-        }
-    }
-}
-
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) enum HostObject {
     Vec(HostVec),
     Map(HostMap),
@@ -58,6 +36,27 @@ pub(crate) enum HostObject {
     Address(xdr::ScAddress),
 }
 
+impl std::fmt::Debug for HostObject {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Vec(arg0) => f.debug_tuple("Vec").field(&arg0.len()).finish(),
+            Self::Map(arg0) => f.debug_tuple("Map").field(&arg0.len()).finish(),
+            Self::U64(arg0) => f.debug_tuple("U64").field(arg0).finish(),
+            Self::I64(arg0) => f.debug_tuple("I64").field(arg0).finish(),
+            Self::TimePoint(arg0) => f.debug_tuple("TimePoint").field(arg0).finish(),
+            Self::Duration(arg0) => f.debug_tuple("Duration").field(arg0).finish(),
+            Self::U128(arg0) => f.debug_tuple("U128").field(arg0).finish(),
+            Self::I128(arg0) => f.debug_tuple("I128").field(arg0).finish(),
+            Self::U256(arg0) => f.debug_tuple("U256").field(arg0).finish(),
+            Self::I256(arg0) => f.debug_tuple("I256").field(arg0).finish(),
+            Self::Bytes(arg0) => f.debug_tuple("Bytes").field(arg0).finish(),
+            Self::String(arg0) => f.debug_tuple("String").field(arg0).finish(),
+            Self::Symbol(arg0) => f.debug_tuple("Symbol").field(arg0).finish(),
+            Self::Address(arg0) => f.debug_tuple("Address").field(arg0).finish(),
+        }
+    }
+}
+
 impl HostObject {
     // Temporarily performs a shallow comparison against a Val of the
     // associated small value type, returning None if the Val is of
diff --git a/soroban-env-host/src/lib.rs b/soroban-env-host/src/lib.rs
index 6c45fd0e..67d0ff02 100644
--- a/soroban-env-host/src/lib.rs
+++ b/soroban-env-host/src/lib.rs
@@ -45,6 +45,9 @@ pub use soroban_env_common::*;
 pub mod e2e_invoke;
 pub mod fees;
 
+#[doc(hidden)]
+pub use host::{TraceEvent, TraceHook, TraceRecord, TraceState};
+
 #[cfg(feature = "bench")]
 #[doc(hidden)]
 pub mod cost_runner;
diff --git a/soroban-env-host/src/macros.rs b/soroban-env-host/src/macros.rs
index 9b80d753..0af84ebe 100644
--- a/soroban-env-host/src/macros.rs
+++ b/soroban-env-host/src/macros.rs
@@ -40,7 +40,6 @@ macro_rules! function_name {
     }};
 }
 
-#[cfg(feature = "testutils")]
 macro_rules! function_short_name {
     () => {{
         fn f() {}
diff --git a/soroban-env-host/src/storage.rs b/soroban-env-host/src/storage.rs
index 2b6ef11c..cd36a8b9 100644
--- a/soroban-env-host/src/storage.rs
+++ b/soroban-env-host/src/storage.rs
@@ -23,20 +23,12 @@ pub type StorageMap = MeteredOrdMap<Rc<LedgerKey>, Option<EntryWithLiveUntil>, B
 /// The in-memory instance storage of the current running contract. Initially
 /// contains entries from the `ScMap` of the corresponding `ScContractInstance`
 /// contract data entry.
-#[derive(Clone)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Hash)]
 pub(crate) struct InstanceStorageMap {
     pub(crate) map: MeteredOrdMap<Val, Val, Host>,
     pub(crate) is_modified: bool,
 }
 
-#[cfg(feature = "testutils")]
-impl std::hash::Hash for StorageMap {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.map.hash(state);
-    }
-}
-
 impl InstanceStorageMap {
     pub(crate) fn from_map(map: Vec<(Val, Val)>, host: &Host) -> Result<Self, HostError> {
         Ok(Self {
@@ -49,8 +41,7 @@ impl InstanceStorageMap {
 /// A helper type used by [Footprint] to designate which ways
 /// a given [LedgerKey] is accessed, or is allowed to be accessed,
 /// in a given transaction.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
 pub enum AccessType {
     /// When in [FootprintMode::Recording], indicates that the [LedgerKey] is only read.
     /// When in [FootprintMode::Enforcing], indicates that the [LedgerKey] is only _allowed_ to be read.
@@ -77,17 +68,9 @@ pub trait SnapshotSource {
 /// running a "preflight" execution in [FootprintMode::Recording],
 /// against a suitably fresh [SnapshotSource].
 // Notes on metering: covered by the underneath `MeteredOrdMap`.
-#[derive(Clone, Default)]
-#[cfg_attr(feature = "testutils", derive(Hash))]
+#[derive(Clone, Default, Hash)]
 pub struct Footprint(pub FootprintMap);
 
-#[cfg(feature = "testutils")]
-impl std::hash::Hash for FootprintMap {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        self.map.hash(state);
-    }
-}
-
 impl Footprint {
     pub fn record_access(
         &mut self,
diff --git a/soroban-env-host/src/vm.rs b/soroban-env-host/src/vm.rs
index 9fca05ea..f0543f8b 100644
--- a/soroban-env-host/src/vm.rs
+++ b/soroban-env-host/src/vm.rs
@@ -16,9 +16,13 @@ mod func_info;
 pub(crate) use dispatch::dummy0;
 
 use crate::{
-    budget::AsBudget,
+    budget::{AsBudget, Budget},
     err,
-    host::{error::TryBorrowOrErr, metered_clone::MeteredContainer},
+    host::{
+        error::TryBorrowOrErr,
+        metered_clone::MeteredContainer,
+        metered_hash::{CountingHasher, MeteredHash},
+    },
     meta::{self, get_ledger_protocol_version},
     xdr::{ContractCostType, Hash, Limited, ReadXdr, ScEnvMetaEntry, ScErrorCode, ScErrorType},
     ConversionError, Host, HostError, Symbol, SymbolStr, TryIntoVal, Val, WasmiMarshal,
@@ -31,9 +35,7 @@ use func_info::HOST_FUNCTIONS;
 
 use wasmi::{Engine, FuelConsumptionMode, Instance, Linker, Memory, Module, Store, Value};
 
-#[cfg(any(test, feature = "testutils"))]
 use crate::VmCaller;
-#[cfg(any(test, feature = "testutils", feature = "bench"))]
 use wasmi::{Caller, StoreContextMut};
 impl wasmi::core::HostError for HostError {}
 
@@ -61,7 +63,6 @@ pub struct Vm {
     pub(crate) memory: Option<Memory>,
 }
 
-#[cfg(feature = "testutils")]
 impl std::hash::Hash for Vm {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         self.contract_id.hash(state);
@@ -478,8 +479,6 @@ impl Vm {
     /// Utility function that synthesizes a `VmCaller<Host>` configured to point
     /// to this VM's `Store` and `Instance`, and calls the provided function
     /// back with it. Mainly used for testing.
-    #[cfg(any(test, feature = "testutils"))]
-    #[allow(unused)]
     pub(crate) fn with_vmcaller<F, T>(&self, f: F) -> Result<T, HostError>
     where
         F: FnOnce(&mut VmCaller<Host>) -> Result<T, HostError>,
@@ -502,38 +501,33 @@ impl Vm {
         f(caller)
     }
 
-    #[cfg(all(test, not(feature = "next"), feature = "testutils"))]
-    pub(crate) fn memory_hash_and_size(&self) -> (u64, usize) {
-        use std::collections::hash_map::DefaultHasher;
-        use std::hash::{Hash, Hasher};
+    pub(crate) fn memory_hash_and_size(&self, budget: &Budget) -> Result<(u64, usize), HostError> {
+        use std::hash::Hasher;
         if let Some(mem) = self.memory {
-            if let Ok((hash, size)) = self.with_vmcaller(|vmcaller| {
-                let mut state = DefaultHasher::default();
+            self.with_vmcaller(|vmcaller| {
+                let mut state = CountingHasher::default();
                 let data = mem.data(vmcaller.try_ref()?);
-                data.hash(&mut state);
+                data.metered_hash(&mut state, budget)?;
                 Ok((state.finish(), data.len()))
-            }) {
-                return (hash, size);
-            }
+            })
+        } else {
+            Ok((0, 0))
         }
-        (0, 0)
     }
 
-    #[cfg(all(test, not(feature = "next"), feature = "testutils"))]
     // This is pretty weak: we just observe the state that wasmi exposes through
     // wasm _exports_. There might be tables or globals a wasm doesn't export
     // but there's no obvious way to observe them.
-    pub(crate) fn exports_hash_and_size(&self) -> (u64, usize) {
-        use std::collections::hash_map::DefaultHasher;
-        use std::hash::{Hash, Hasher};
+    pub(crate) fn exports_hash_and_size(&self, budget: &Budget) -> Result<(u64, usize), HostError> {
+        use std::hash::Hasher;
         use wasmi::{Extern, StoreContext};
-        if let Ok((hash, size)) = self.with_vmcaller(|vmcaller| {
+        self.with_vmcaller(|vmcaller| {
             let ctx: StoreContext<'_, _> = vmcaller.try_ref()?.into();
             let mut size: usize = 0;
-            let mut state = DefaultHasher::default();
+            let mut state = CountingHasher::default();
             for export in self.instance.exports(vmcaller.try_ref()?) {
                 size = size.saturating_add(1);
-                export.name().hash(&mut state);
+                export.name().metered_hash(&mut state, budget)?;
 
                 match export.into_extern() {
                     // Funcs are immutable, memory we hash separately above.
@@ -541,26 +535,30 @@ impl Vm {
 
                     Extern::Table(t) => {
                         let sz = t.size(&ctx);
-                        sz.hash(&mut state);
+                        sz.metered_hash(&mut state, budget)?;
                         size = size.saturating_add(sz as usize);
                         for i in 0..sz {
                             if let Some(elem) = t.get(&ctx, i) {
+                                // This is a slight fudge to avoid having to
+                                // define a ton of additional MeteredHash impls
+                                // for wasmi substructures, since there is a
+                                // bounded size on the string representation of
+                                // a value, we're comfortable going temporarily
+                                // over budget here.
                                 let s = format!("{:?}", elem);
-                                s.hash(&mut state);
+                                budget.charge(ContractCostType::MemAlloc, Some(s.len() as u64))?;
+                                s.metered_hash(&mut state, budget)?;
                             }
                         }
                     }
                     Extern::Global(g) => {
                         let s = format!("{:?}", g.get(&ctx));
-                        s.hash(&mut state);
+                        budget.charge(ContractCostType::MemAlloc, Some(s.len() as u64))?;
+                        s.metered_hash(&mut state, budget)?;
                     }
                 }
             }
             Ok((state.finish(), size))
-        }) {
-            (hash, size)
-        } else {
-            (0, 0)
-        }
+        })
     }
 }
diff --git a/soroban-env-host/src/vm/dispatch.rs b/soroban-env-host/src/vm/dispatch.rs
index ea4f943f..0b16cf25 100644
--- a/soroban-env-host/src/vm/dispatch.rs
+++ b/soroban-env-host/src/vm/dispatch.rs
@@ -8,6 +8,7 @@ use crate::{
     I64Object, MapObject, StorageType, StringObject, Symbol, SymbolObject, TimepointObject,
     U128Object, U256Object, U256Val, U32Val, U64Object, U64Val, Val, VecObject, Void,
 };
+use core::fmt::Debug;
 use soroban_env_common::{call_macro_with_all_host_functions, WasmiMarshal};
 use wasmi::{
     core::{Trap, TrapCode::BadSignature},
@@ -50,6 +51,43 @@ macro_rules! impl_relative_object_conversion {
     };
 }
 
+enum TraceArg<T: Debug> {
+    Bad(i64),
+    Ok(T),
+}
+impl<T> Debug for TraceArg<T>
+where
+    T: Debug,
+{
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TraceArg::Bad(i) => write!(f, "bad:{:?}", i),
+            TraceArg::Ok(t) => write!(f, "{:?}", t),
+        }
+    }
+}
+
+macro_rules! homogenize_tuple {
+    ($u:ident, ()) => {
+        &[]
+    };
+    ($u:ident, ($_a:expr)) => {
+        &[&$u]
+    };
+    ($u:ident, ($_a:expr, $_b:expr)) => {
+        &[&$u.0, &$u.1]
+    };
+    ($u:ident, ($_a:expr, $_b:expr, $_c:expr)) => {
+        &[&$u.0, &$u.1, &$u.2]
+    };
+    ($u:ident, ($_a:expr, $_b:expr, $_c:expr, $_d:expr)) => {
+        &[&$u.0, &$u.1, &$u.2, &$u.3]
+    };
+    ($u:ident, ($_a:expr, $_b:expr, $_c:expr, $_d:expr, $_e:expr)) => {
+        &[&$u.0, &$u.1, &$u.2, &$u.3, &$u.4]
+    };
+}
+
 // Define a relative-to-absolute impl for any type that is (a) mentioned
 // in a host function type signature in env and (b) might possibly carry an
 // object reference. If you miss one, this file won't compile, so it's safe.
@@ -160,19 +198,17 @@ macro_rules! generate_dispatch_functions {
 
                     let host = caller.data().clone();
 
-                    #[cfg(feature = "testutils")]
+                    if host.tracing_enabled()
                     {
-                        host.env_call_hook(&core::stringify!($fn_id), &[$(
-                            // Incoming args might or might-not be type-correct;
-                            // we attempt to unmarshal here but fail safely and
-                            // log the bad i64 if it doesn't work. The failure
-                            // will be repeated below in the attempted call, and
-                            // will propagate.
+                        #[allow(unused)]
+                        let trace_args = ($(
                             match <$type>::try_marshal_from_relative_value(Value::I64($arg), &host) {
-                                Ok(val) => format!("{:?}", val),
-                                Err(_) => format!("bad:{:?}", $arg),
+                                Ok(val) => TraceArg::Ok(val),
+                                Err(_) => TraceArg::Bad($arg),
                             }
-                        ),*])?;
+                        ),*);
+                        let hook_args: &[&dyn std::fmt::Debug] = homogenize_tuple!(trace_args, ($($arg),*));
+                        host.trace_env_call(&core::stringify!($fn_id), hook_args)?;
                     }
 
                     // This is where the VM -> Host boundary is crossed.
@@ -196,13 +232,13 @@ macro_rules! generate_dispatch_functions {
                     // wasmi::Value.
                     let res: Result<_, HostError> = host.$fn_id(&mut vmcaller, $(<$type>::check_env_arg(<$type>::try_marshal_from_relative_value(Value::I64($arg), &host)?, &host)?),*);
 
-                    #[cfg(feature = "testutils")]
+                    if host.tracing_enabled()
                     {
-                        let res_str: Result<String,&HostError> = match &res {
-                            Ok(ok) => Ok(format!("{:?}", ok)),
+                        let dyn_res: Result<&dyn core::fmt::Debug,&HostError> = match &res {
+                            Ok(ref ok) => Ok(ok),
                             Err(err) => Err(err)
                         };
-                        host.env_ret_hook(&core::stringify!($fn_id), &res_str)?;
+                        host.trace_env_ret(&core::stringify!($fn_id), &dyn_res)?;
                     }
 
                     // On the off chance we got an error with no context, we can


Test patch:
diff --git a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_off.json b/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_off.json
deleted file mode 100644
index e01a3e8d..00000000
--- a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_off.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
-  "   1 end": "cpu:0, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
-}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_insufficient_budget.json b/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_insufficient_budget.json
deleted file mode 100644
index e01a3e8d..00000000
--- a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_insufficient_budget.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
-  "   1 end": "cpu:0, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
-}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_sufficient_budget.json b/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_sufficient_budget.json
deleted file mode 100644
index e01a3e8d..00000000
--- a/soroban-env-host/observations/test__event__test_diagnostic_events_do_not_affect_metering_with_debug_on_and_sufficient_budget.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
-  "   1 end": "cpu:0, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-"
-}
\ No newline at end of file
diff --git a/soroban-env-host/observations/test__hostile__excessive_logging.json b/soroban-env-host/observations/test__hostile__excessive_logging.json
deleted file mode 100644
index 2def44a9..00000000
--- a/soroban-env-host/observations/test__hostile__excessive_logging.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
-  "   0 begin": "cpu:14488, mem:0, prngs:-/9b4a753, objs:-/-, vm:-/-, evt:-, store:-/-, foot:-, stk:-, auth:-/-",
-  "   1 call bytes_new_from_slice(128)": "cpu:14535",
-  "   2 ret bytes_new_from_slice -> Ok(Bytes(obj#1))": "cpu:15528, mem:208, objs:-/1@8fbabb1a",
-  "   3 call upload_wasm(Bytes(obj#1))": "",
-  "   4 ret upload_wasm -> Ok(Bytes(obj#3))": "cpu:538375, mem:201851, objs:-/2@a509e81d, store:-/1@9cd3366c, foot:1@621ea539",
-  "   5 call bytes_new_from_slice(32)": "cpu:538815, mem:201915, objs:-/3@80a8a45",
-  "   6 ret bytes_new_from_slice -> Ok(Bytes(obj#7))": "cpu:539784, mem:202027, objs:-/4@70db9251",
-  "   7 call create_contract(Address(obj#5), Bytes(obj#3), Bytes(obj#7))": "",
-  "   8 call obj_cmp(Address(obj#9), Address(obj#5))": "cpu:541427, mem:202205, objs:-/5@1d9e0436, auth:1@d8fcacb9/-",
-  "   9 ret obj_cmp -> Ok(0)": "cpu:541719",
-  "  10 call get_ledger_network_id()": "cpu:541769, auth:1@d8fcacb9/1@55ce3077",
-  "  11 ret get_ledger_network_id -> Ok(Bytes(obj#11))": "cpu:542799, mem:202317, objs:-/6@f4c6cf9e",
-  "  12 ret create_contract -> Ok(Address(obj#13))": "cpu:560932, mem:205391, objs:-/7@3938d66d, store:-/2@42e19808, foot:2@cb5493ab, auth:-/1@bfbdc66c",
-  "  13 call call(Address(obj#13), Symbol(test), Vec(obj#15))": "cpu:969, mem:112, objs:-/8@1685282, auth:-/-",
-  "  14 push VM:704e0636:test(U32(0), U32(10), U32(0), U32(10))": "cpu:519220, mem:202297, objs:-/9@959b10e0, vm:65536@b1cd98b9/2@2f94d90d, stk:1@e04e2a15, auth:1@d7dc1364/-",
-  "  15 call log_from_linear_memory(U32(0), U32(10), U32(0), U32(10))": "cpu:521705, mem:202391, vm:-/-",
-  "  16 ret log_from_linear_memory -> Ok(Void)": "cpu:522315",
-  "  17 pop VM:704e0636:test -> Ok(Symbol(pass))": " vm:65536@b1cd98b9/2@2f94d90d",
-  "  18 ret call -> Ok(Symbol(pass))": " vm:-/-, stk:-, auth:-/-",
-  "  19 call call(Address(obj#13), Symbol(test), Vec(obj#19))": "cpu:969, mem:112, objs:-/10@d3aa73dc",
-  "  20 push VM:704e0636:test(U32(0), U32(65536), U32(0), U32(8192))": "cpu:519220, mem:202297, objs:-/11@c60e154f, vm:65536@b1cd98b9/2@2f94d90d, stk:1@8c55b6da, auth:1@b1b428e/-",
-  "  21 call log_from_linear_memory(U32(0), U32(65536), U32(0), U32(8192))": "cpu:521705, mem:202391, vm:-/-",
-  "  22 ret log_from_linear_memory -> Ok(Void)": "cpu:522315",
-  "  23 pop VM:704e0636:test -> Ok(Symbol(pass))": " vm:65536@b1cd98b9/2@2f94d90d",
-  "  24 ret call -> Ok(Symbol(pass))": " vm:-/-, stk:-, auth:-/-",
-  "  25 call call(Address(obj#13), Symbol(test), Vec(obj#23))": "cpu:969, mem:112, objs:-/12@b43df649",
-  "  26 push VM:704e0636:test(U32(0), U32(65536), U32(0), U32(8192))": "cpu:519220, mem:202297, objs:-/13@998a4802, vm:65536@b1cd98b9/2@2f94d90d, stk:1@8c55b6da, auth:1@73b1caf7/-",
-  "  27 call log_from_linear_memory(U32(0), U32(65536), U32(0), U32(8192))": "cpu:521705, mem:202391, vm:-/-",
-  "  28 ret log_from_linear_memory -> Ok(Void)": "cpu:522315",
-  "  29 pop VM:704e0636:test -> Ok(Symbol(pass))": " vm:65536@b1cd98b9/2@2f94d90d",
-  "  30 ret call -> Ok(Symbol(pass))": " vm:-/-, stk:-, auth:-/-",
-  "  31 end": "cpu:522315, mem:202391, prngs:-/9b4a753, objs:-/13@998a4802, vm:-/-, evt:-, store:-/2@42e19808, foot:2@cb5493ab, stk:-, auth:-/-"
-}
\ No newline at end of file
diff --git a/soroban-env-host/src/e2e_invoke.rs b/soroban-env-host/src/e2e_invoke.rs
index 36c34911..3e109fb9 100644
--- a/soroban-env-host/src/e2e_invoke.rs
+++ b/soroban-env-host/src/e2e_invoke.rs
@@ -13,6 +13,7 @@ use crate::{
         ledger_info_helper::get_key_durability,
         metered_clone::{MeteredAlloc, MeteredClone, MeteredContainer, MeteredIterator},
         metered_xdr::{metered_from_xdr_with_budget, metered_write_xdr},
+        TraceHook,
     },
     storage::{AccessType, Footprint, FootprintMap, SnapshotSource, Storage, StorageMap},
     xdr::{
@@ -231,6 +232,39 @@ pub fn invoke_host_function<T: AsRef<[u8]>, I: ExactSizeIterator<Item = T>>(
     encoded_ttl_entries: I,
     base_prng_seed: T,
     diagnostic_events: &mut Vec<DiagnosticEvent>,
+) -> Result<InvokeHostFunctionResult, HostError> {
+    invoke_host_function_with_trace_hook(
+        budget,
+        enable_diagnostics,
+        encoded_host_fn,
+        encoded_resources,
+        encoded_source_account,
+        encoded_auth_entries,
+        ledger_info,
+        encoded_ledger_entries,
+        encoded_ttl_entries,
+        base_prng_seed,
+        diagnostic_events,
+        None,
+    )
+}
+
+/// Same as `invoke_host_function` but allows to pass a trace hook which will
+/// be installed in the host for the duration of the invocation.
+#[allow(clippy::too_many_arguments)]
+pub fn invoke_host_function_with_trace_hook<T: AsRef<[u8]>, I: ExactSizeIterator<Item = T>>(
+    budget: &Budget,
+    enable_diagnostics: bool,
+    encoded_host_fn: T,
+    encoded_resources: T,
+    encoded_source_account: T,
+    encoded_auth_entries: I,
+    ledger_info: LedgerInfo,
+    encoded_ledger_entries: I,
+    encoded_ttl_entries: I,
+    base_prng_seed: T,
+    diagnostic_events: &mut Vec<DiagnosticEvent>,
+    trace_hook: Option<TraceHook>,
 ) -> Result<InvokeHostFunctionResult, HostError> {
     let _span0 = tracy_span!("invoke_host_function");
 
@@ -250,6 +284,10 @@ pub fn invoke_host_function<T: AsRef<[u8]>, I: ExactSizeIterator<Item = T>>(
 
     let storage = Storage::with_enforcing_footprint_and_map(footprint, storage_map);
     let host = Host::with_storage_and_budget(storage, budget.clone());
+    let have_trace_hook = trace_hook.is_some();
+    if let Some(th) = trace_hook {
+        host.set_trace_hook(Some(th))?;
+    }
     let auth_entries = host.build_auth_entries_from_xdr(encoded_auth_entries)?;
     let host_function: HostFunction = host.metered_from_xdr(encoded_host_fn.as_ref())?;
     let source_account: AccountId = host.metered_from_xdr(encoded_source_account.as_ref())?;
@@ -272,6 +310,9 @@ pub fn invoke_host_function<T: AsRef<[u8]>, I: ExactSizeIterator<Item = T>>(
         let _span1 = tracy_span!("Host::invoke_function");
         host.invoke_function(host_function)
     };
+    if have_trace_hook {
+        host.set_trace_hook(None)?;
+    }
     let (storage, events) = host.try_finish()?;
     if enable_diagnostics {
         extract_diagnostic_events(&events, diagnostic_events);
diff --git a/soroban-env-host/src/test/event.rs b/soroban-env-host/src/test/event.rs
index cf9d75fe..f4789e0b 100644
--- a/soroban-env-host/src/test/event.rs
+++ b/soroban-env-host/src/test/event.rs
@@ -169,7 +169,7 @@ fn test_internal_diagnostic_event_metering_free() -> Result<(), HostError> {
     Ok(())
 }
 
-fn log_some_diagnostics(host: crate::test::observe::ObservedHost) -> Result<Events, HostError> {
+fn log_some_diagnostics(host: Host) -> Result<Events, HostError> {
     let args: Vec<_> = (0..1000).map(|u| Val::from_u32(u).to_val()).collect();
     let contract_id = Hash([0; 32]);
     host.log_diagnostics("logging some diagnostics", args.as_slice());
@@ -184,16 +184,16 @@ fn log_some_diagnostics(host: crate::test::observe::ObservedHost) -> Result<Even
         &Symbol::try_from_small_str("fn_return")?,
         &Symbol::try_from_small_str("pass")?.into(),
     );
-    let realhost = (*host).clone();
-    drop(host);
-    let (_, evts) = realhost.try_finish()?;
+    let (_, evts) = host.try_finish()?;
     Ok(evts)
 }
 
 #[test]
 fn test_diagnostic_events_do_not_affect_metering_with_debug_off() -> Result<(), HostError> {
     // DEBUG mode OFF
-    let host = observe_host!(Host::test_host());
+
+    // NB: We don't observe here since the test is sensitive to shadow budget.
+    let host = Host::test_host();
     let budget = host.as_budget().clone();
     budget.reset_default()?;
     let evts = log_some_diagnostics(host)?;
@@ -209,7 +209,9 @@ fn test_diagnostic_events_do_not_affect_metering_with_debug_off() -> Result<(),
 fn test_diagnostic_events_do_not_affect_metering_with_debug_on_and_sufficient_budget(
 ) -> Result<(), HostError> {
     // DEBUG mode ON, budget sufficient
-    let host = observe_host!(Host::test_host());
+
+    // NB: We don't observe here since the test is sensitive to shadow budget.
+    let host = Host::test_host();
     host.enable_debug()?;
     let budget = host.as_budget().clone();
     budget.reset_default()?;
@@ -226,7 +228,9 @@ fn test_diagnostic_events_do_not_affect_metering_with_debug_on_and_sufficient_bu
 fn test_diagnostic_events_do_not_affect_metering_with_debug_on_and_insufficient_budget(
 ) -> Result<(), HostError> {
     // DEBUG mode ON, budget insufficient
-    let host = observe_host!(Host::test_host());
+
+    // NB: We don't observe here since the test is sensitive to shadow budget.
+    let host = Host::test_host();
     host.enable_debug()?;
     let budget = host.as_budget().clone();
     budget.reset_default()?;
diff --git a/soroban-env-host/src/test/hostile.rs b/soroban-env-host/src/test/hostile.rs
index b656ecaf..b8a7af40 100644
--- a/soroban-env-host/src/test/hostile.rs
+++ b/soroban-env-host/src/test/hostile.rs
@@ -523,7 +523,8 @@ fn broken_object() {
 #[test]
 fn excessive_logging() -> Result<(), HostError> {
     let wasm = wasm_util::wasm_module_with_linear_memory_logging();
-    let host = observe_host!(Host::test_host_with_recording_footprint());
+    // NB: We don't observe here since the test is sensitive to shadow budget.
+    let host = Host::test_host_with_recording_footprint();
     host.enable_debug()?;
     let contract_id_obj = host.register_test_contract_wasm(wasm.as_slice());
 
diff --git a/soroban-env-host/src/test/observe.rs b/soroban-env-host/src/test/observe.rs
index 212f195c..496d1b71 100644
--- a/soroban-env-host/src/test/observe.rs
+++ b/soroban-env-host/src/test/observe.rs
@@ -1,14 +1,7 @@
 // This is a test-support module that exists to help us notice when we change
-// the semantics of the host unintentionally. It works by recording a trace of
-// "observations" at each "step" of a running test. Steps mostly correspond to
-// the host lifecycle events -- frame push/pop and host function calls -- as
-// well as a step at the end of the test.
-//
-// Observations include a short summary of everything a contract _might_
-// observe: local and global objects, local and global storage, events, PRNGs,
-// guest linear memory, and budgets. The results are stored in maps and saved to
-// disk in files called "observations/some_testname.json" based on the testnames
-// passed in.
+// the semantics of the host unintentionally. It works by hooking into the
+// host's tracing system, and recording the trace of a test into a reference
+// file"observations/some_testname.json" associated with each named test.
 //
 // If observations differ from previous runs, it's considered an error. If you
 // want to make an intentional change, run with UPDATE_OBSERVATIONS=1.
@@ -19,24 +12,16 @@
     allow(unused_imports)
 )]
 
-#[cfg(all(not(feature = "next"), feature = "testutils"))]
-use crate::host::{error::HostError, HostLifecycleEvent};
-
 use crate::{
-    budget::AsBudget, events::InternalEvent, host::Frame, xdr::ContractExecutable, Host, Symbol,
-    SymbolObject, SymbolSmall,
+    host::{
+        error::HostError,
+        trace::{TraceEvent, TraceRecord},
+    },
+    Host,
 };
+
 use itertools::Itertools;
-use std::{
-    cell::RefCell,
-    collections::{hash_map::DefaultHasher, BTreeMap},
-    env,
-    fmt::Display,
-    fs::File,
-    hash::{Hash, Hasher},
-    path::PathBuf,
-    rc::Rc,
-};
+use std::{cell::RefCell, collections::BTreeMap, env, fs::File, path::PathBuf, rc::Rc};
 
 fn full_path(testname: &str) -> PathBuf {
     let testname = if let Some((_, rest)) = testname.split_once("::") {
@@ -99,10 +84,10 @@ impl Observations {
     // _not_ in update_observations mode (i.e. it's enforcing) it also calls
     // assert_eq! on the observations at this point, which will cause an
     // observed test to fail if there were differences from the old recording.
-    fn check(old: &Observations, new: &mut Observations, name: &'static str, ob: Observation) {
+    fn check(old: &Observations, new: &mut Observations, name: &'static str, tr: TraceRecord) {
         let mut disagreement: Option<(usize, String, String)> = None;
 
-        if ob.step == Step::Begin {
+        if tr.event.is_begin() {
             new.0.clear();
         }
 
@@ -111,16 +96,19 @@ impl Observations {
         // the last line's full state, for greater compactness and legibility.
         const PREV_FULL: &str = "___PREV_FULL";
         let prev = new.0.remove(PREV_FULL).unwrap_or_default();
-        let (id, full) = ob.render(new.0.len());
-        new.0.insert(PREV_FULL.to_string(), full.clone());
-        if ob.step == Step::Begin || ob.step == Step::End {
-            new.0.insert(id.clone(), full);
+
+        let key = format!("{:4.4} {}", new.0.len(), tr.event);
+        let value = format!("{}", tr.state);
+
+        new.0.insert(PREV_FULL.to_string(), value.clone());
+        if tr.event.is_begin() || tr.event.is_end() {
+            new.0.insert(key.clone(), value);
         } else {
-            let diff = diff_line(&prev, &full);
-            new.0.insert(id.clone(), diff);
+            let diff = diff_line(&prev, &value);
+            new.0.insert(key.clone(), diff);
         }
 
-        if ob.step == Step::End {
+        if tr.event.is_end() {
             new.0.remove(PREV_FULL);
             if old.0.len() != new.0.len() {
                 println!("old and new observations of {name} have different lengths");
@@ -163,389 +151,6 @@ impl Observations {
 #[derive(Debug, Default, Clone)]
 struct Observations(BTreeMap<String, String>);
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-enum Step {
-    Begin,
-    Push {
-        contract: String,
-        func: String,
-        args: Vec<String>,
-    },
-    Pop {
-        contract: String,
-        func: String,
-        result: String,
-    },
-    Call(String, Vec<String>),
-    Ret(String, Result<String, String>),
-    End,
-}
-
-impl Display for Step {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Step::Push {
-                contract,
-                func,
-                args,
-            } => {
-                write!(f, "push {contract}:{func}(")?;
-                for (i, arg) in args.iter().enumerate() {
-                    if i != 0 {
-                        write!(f, ", ")?;
-                    }
-                    write!(f, "{}", arg)?;
-                }
-                write!(f, ")")
-            }
-            Step::Pop {
-                contract,
-                func,
-                result,
-            } => {
-                write!(f, "pop {contract}:{func} -> {result}")
-            }
-            Step::Call(hostfn, args) => {
-                write!(f, "call {}(", hostfn)?;
-                for (i, arg) in args.iter().enumerate() {
-                    if i != 0 {
-                        write!(f, ", ")?;
-                    }
-                    write!(f, "{}", arg)?;
-                }
-                write!(f, ")")
-            }
-            Step::Ret(hostfn, Ok(ok)) => {
-                write!(f, "ret {} -> Ok({})", hostfn, ok)
-            }
-            Step::Ret(hostfn, Err(err)) => {
-                write!(f, "ret {} -> Err({})", hostfn, err)
-            }
-            Step::Begin => write!(f, "begin"),
-            Step::End => write!(f, "end"),
-        }
-    }
-}
-#[derive(Clone, Debug, PartialEq, Eq)]
-struct Observation {
-    step: Step,
-    cpu_insns: u64,
-    mem_bytes: u64,
-    local_prng_hash: u64,
-    base_prng_hash: u64,
-    local_objs_size: usize,
-    local_objs_hash: u64,
-    global_objs_size: usize,
-    global_objs_hash: u64,
-    vm_mem_size: usize,
-    vm_mem_hash: u64,
-    vm_exports_size: usize,
-    vm_exports_hash: u64,
-    events_size: usize,
-    events_hash: u64,
-    instance_storage_size: usize,
-    instance_storage_hash: u64,
-    ledger_storage_size: usize,
-    ledger_storage_hash: u64,
-    storage_footprint_size: usize,
-    storage_footprint_hash: u64,
-    auth_stack_size: usize,
-    auth_stack_hash: u64,
-    auth_trackers_size: usize,
-    auth_trackers_hash: u64,
-    context_stack_size: usize,
-    context_stack_hash: u64,
-}
-
-fn hash_one<H: Hash>(h: &H) -> u64 {
-    let mut state = DefaultHasher::default();
-    h.hash(&mut state);
-    state.finish()
-}
-
-fn hash_iter<H: Hash>(h: impl Iterator<Item = H>) -> u64 {
-    let mut state = DefaultHasher::default();
-    h.for_each(|i| i.hash(&mut state));
-    state.finish()
-}
-
-fn hash_optional<H: Hash>(h: &Option<H>) -> u64 {
-    if let Some(x) = h {
-        hash_one(x)
-    } else {
-        0
-    }
-}
-
-#[cfg(all(not(feature = "next"), feature = "testutils"))]
-impl Host {
-    fn base_prng_hash(&self) -> u64 {
-        if let Ok(prng) = self.try_borrow_base_prng() {
-            hash_optional(&*prng)
-        } else {
-            0
-        }
-    }
-    fn local_prng_hash(&self) -> u64 {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                return hash_optional(&ctx.prng);
-            }
-        }
-        0
-    }
-    fn local_objs_size(&self) -> usize {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Frame::ContractVM {
-                    relative_objects, ..
-                } = &ctx.frame
-                {
-                    return relative_objects.len();
-                }
-            }
-        }
-        0
-    }
-    fn local_objs_hash(&self) -> u64 {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Frame::ContractVM {
-                    relative_objects, ..
-                } = &ctx.frame
-                {
-                    return hash_iter(relative_objects.iter().map(|x| x.to_val().get_payload()));
-                }
-            }
-        }
-        0
-    }
-    fn global_objs_size(&self) -> usize {
-        if let Ok(objs) = self.try_borrow_objects() {
-            objs.len()
-        } else {
-            0
-        }
-    }
-    fn global_objs_hash(&self) -> u64 {
-        if let Ok(objs) = self.try_borrow_objects() {
-            hash_iter(objs.iter())
-        } else {
-            0
-        }
-    }
-    fn memory_hash_and_size(&self) -> (u64, usize) {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Frame::ContractVM { vm, .. } = &ctx.frame {
-                    return vm.memory_hash_and_size();
-                }
-            }
-        }
-        (0, 0)
-    }
-    fn events_size(&self) -> usize {
-        if let Ok(evts) = self.try_borrow_events() {
-            evts.vec
-                .iter()
-                .filter(|(e, _)| match e {
-                    InternalEvent::Contract(_) => true,
-                    InternalEvent::Diagnostic(_) => false,
-                })
-                .count()
-        } else {
-            0
-        }
-    }
-    fn events_hash(&self) -> u64 {
-        if let Ok(evts) = self.try_borrow_events() {
-            hash_iter(evts.vec.iter().filter(|(e, _)| match e {
-                InternalEvent::Contract(_) => true,
-                InternalEvent::Diagnostic(_) => false,
-            }))
-        } else {
-            0
-        }
-    }
-    fn instance_storage_size(&self) -> usize {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Some(storage) = &ctx.storage {
-                    return storage.map.len();
-                }
-            }
-        }
-        0
-    }
-    fn instance_storage_hash(&self) -> u64 {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Some(storage) = &ctx.storage {
-                    return hash_one(&storage.map);
-                }
-            }
-        }
-        0
-    }
-    fn ledger_storage_size(&self) -> usize {
-        if let Ok(store) = self.try_borrow_storage() {
-            store.map.len()
-        } else {
-            0
-        }
-    }
-    fn ledger_storage_hash(&self) -> u64 {
-        if let Ok(store) = self.try_borrow_storage() {
-            hash_one(&store.map)
-        } else {
-            0
-        }
-    }
-
-    fn auth_stack_size(&self) -> usize {
-        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
-            auth_mgr.stack_size()
-        } else {
-            0
-        }
-    }
-    fn auth_stack_hash(&self) -> u64 {
-        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
-            auth_mgr.stack_hash()
-        } else {
-            0
-        }
-    }
-    fn auth_trackers_hash_and_size(&self) -> (u64, usize) {
-        if let Ok(auth_mgr) = self.try_borrow_authorization_manager() {
-            auth_mgr.trackers_hash_and_size()
-        } else {
-            (0, 0)
-        }
-    }
-    fn context_stack_size(&self) -> usize {
-        if let Ok(frames) = self.try_borrow_context_stack() {
-            frames.len()
-        } else {
-            0
-        }
-    }
-    fn context_stack_hash(&self) -> u64 {
-        if let Ok(frames) = self.try_borrow_context_stack() {
-            hash_iter(frames.iter())
-        } else {
-            0
-        }
-    }
-    fn storage_footprint_size(&self) -> usize {
-        if let Ok(storage) = self.try_borrow_storage() {
-            storage.footprint.0.len()
-        } else {
-            0
-        }
-    }
-    fn storage_footprint_hash(&self) -> u64 {
-        if let Ok(storage) = self.try_borrow_storage() {
-            hash_one(&storage.footprint.0)
-        } else {
-            0
-        }
-    }
-
-    fn vm_exports_hash_and_size(&self) -> (u64, usize) {
-        if let Ok(ctxs) = self.try_borrow_context_stack() {
-            if let Some(ctx) = ctxs.last() {
-                if let Frame::ContractVM { vm, .. } = &ctx.frame {
-                    return vm.exports_hash_and_size();
-                }
-            }
-        }
-        (0, 0)
-    }
-}
-
-#[cfg(all(not(feature = "next"), feature = "testutils"))]
-impl Observation {
-    fn render_hash(hash: u64) -> String {
-        // Truncate the hash to u32, to fit on a line.
-        if hash == 0 {
-            "-".to_string()
-        } else {
-            format!("{:4.4x}", hash as u32)
-        }
-    }
-
-    fn render_size_and_hash(size: usize, hash: u64) -> String {
-        if size == 0 {
-            "-".to_string()
-        } else {
-            format!("{}@{}", size, Self::render_hash(hash))
-        }
-    }
-
-    // Render needs to return a (key,value) pair for step number `n` in a given
-    // test. The returned key needs to be unique and should probably start with
-    // a rendering of the number `n`.
-    fn render(&self, n: usize) -> (String, String) {
-        (
-            format!("{:4.4} {}", n, self.step),
-            format!(
-                "cpu:{}, mem:{}, prngs:{}/{}, objs:{}/{}, vm:{}/{}, evt:{}, store:{}/{}, foot:{}, stk:{}, auth:{}/{}",
-                self.cpu_insns,
-                self.mem_bytes,
-                Self::render_hash(self.local_prng_hash),
-                Self::render_hash(self.base_prng_hash),
-                Self::render_size_and_hash(self.local_objs_size, self.local_objs_hash),
-                Self::render_size_and_hash(self.global_objs_size, self.global_objs_hash),
-                Self::render_size_and_hash(self.vm_mem_size, self.vm_mem_hash),
-                Self::render_size_and_hash(self.vm_exports_size, self.vm_exports_hash),
-                Self::render_size_and_hash(self.events_size, self.events_hash),
-                Self::render_size_and_hash(self.instance_storage_size, self.instance_storage_hash),
-                Self::render_size_and_hash(self.ledger_storage_size, self.ledger_storage_hash),
-                Self::render_size_and_hash(self.storage_footprint_size, self.storage_footprint_hash),
-                Self::render_size_and_hash(self.context_stack_size, self.context_stack_hash),
-                Self::render_size_and_hash(self.auth_stack_size, self.auth_stack_hash),
-                Self::render_size_and_hash(self.auth_trackers_size, self.auth_trackers_hash),
-            ),
-        )
-    }
-    fn observe(host: &Host, step: Step) -> Self {
-        let (vm_mem_hash, vm_mem_size) = host.memory_hash_and_size();
-        let (vm_exports_hash, vm_exports_size) = host.vm_exports_hash_and_size();
-        let (auth_trackers_hash, auth_trackers_size) = host.auth_trackers_hash_and_size();
-
-        Observation {
-            step,
-            cpu_insns: host.as_budget().get_cpu_insns_consumed().unwrap(),
-            mem_bytes: host.as_budget().get_mem_bytes_consumed().unwrap(),
-            local_prng_hash: host.local_prng_hash(),
-            base_prng_hash: host.base_prng_hash(),
-            local_objs_size: host.local_objs_size(),
-            local_objs_hash: host.local_objs_hash(),
-            global_objs_size: host.global_objs_size(),
-            global_objs_hash: host.global_objs_hash(),
-            vm_mem_size,
-            vm_mem_hash,
-            vm_exports_size,
-            vm_exports_hash,
-            events_size: host.events_size(),
-            events_hash: host.events_hash(),
-            instance_storage_size: host.instance_storage_size(),
-            instance_storage_hash: host.instance_storage_hash(),
-            ledger_storage_size: host.ledger_storage_size(),
-            ledger_storage_hash: host.ledger_storage_hash(),
-            storage_footprint_size: host.storage_footprint_size(),
-            storage_footprint_hash: host.storage_footprint_hash(),
-            context_stack_size: host.context_stack_size(),
-            context_stack_hash: host.context_stack_hash(),
-            auth_stack_size: host.auth_stack_size(),
-            auth_stack_hash: host.auth_stack_hash(),
-            auth_trackers_size,
-            auth_trackers_hash,
-        }
-    }
-}
-
 pub(crate) struct ObservedHost {
     testname: &'static str,
     old_obs: Rc<RefCell<Observations>>,
@@ -553,83 +158,6 @@ pub(crate) struct ObservedHost {
     host: Host,
 }
 
-impl Step {
-    #[cfg(all(not(feature = "next"), feature = "testutils"))]
-    fn from_host_lifecycle_event(evt: HostLifecycleEvent<'_>) -> Self {
-        match evt {
-            HostLifecycleEvent::PushCtx(context) => {
-                let (contract, func, args) = Self::frame_contract_func_and_args(&context.frame);
-                Step::Push {
-                    contract,
-                    func,
-                    args,
-                }
-            }
-            HostLifecycleEvent::PopCtx(context, result) => {
-                let (contract, func, _) = Self::frame_contract_func_and_args(&context.frame);
-                // We only want to see the error code here, not debuginfo.
-                let result = format!("{:?}", result.clone().map_err(|he| he.error));
-                Step::Pop {
-                    contract,
-                    func,
-                    result,
-                }
-            }
-            HostLifecycleEvent::EnvCall(fname, args) => {
-                Step::Call(fname.to_string(), args.iter().cloned().collect())
-            }
-            HostLifecycleEvent::EnvRet(fname, res) => Step::Ret(fname.to_string(), res.clone()),
-        }
-    }
-    fn short_hash(hash: &crate::xdr::Hash) -> String {
-        let word: [u8; 4] = hash.0[0..4]
-            .try_into()
-            .expect("extracting 4 bytes from hash");
-        format!("{:4.4x}", u32::from_be_bytes(word))
-    }
-    fn exec(exec: &ContractExecutable) -> String {
-        match exec {
-            ContractExecutable::Wasm(hash) => Self::short_hash(hash),
-            ContractExecutable::StellarAsset => "SAC".to_string(),
-        }
-    }
-    fn sym(s: &Symbol) -> String {
-        if let Ok(small) = SymbolSmall::try_from(s.clone()) {
-            small.to_string()
-        } else if let Ok(obj) = SymbolObject::try_from(s.clone()) {
-            format!("sym#{}", obj.get_handle())
-        } else {
-            format!("{:?}", s)
-        }
-    }
-    fn frame_contract_func_and_args(frame: &Frame) -> (String, String, Vec<String>) {
-        match frame {
-            Frame::ContractVM {
-                fn_name,
-                args,
-                instance,
-                ..
-            } => (
-                format!("VM:{}", Self::exec(&instance.executable)),
-                format!("{}", Self::sym(fn_name)),
-                args.iter().map(|arg| format!("{:?}", arg)).collect(),
-            ),
-            // TODO: this isn't ideal, we should capture the args in Frame::HostFunction
-            Frame::HostFunction(ty) => (format!("{:?}", ty), "".to_string(), vec![]),
-            Frame::StellarAssetContract(id, fn_name, args, _) => (
-                format!("SAC:{}", Self::short_hash(id)),
-                format!("{}", Self::sym(fn_name)),
-                args.iter().map(|arg| format!("{:?}", arg)).collect(),
-            ),
-            Frame::TestContract(tc) => (
-                format!("TEST:{}", Self::short_hash(&tc.id)),
-                format!("{}", Self::sym(&tc.func)),
-                tc.args.iter().map(|arg| format!("{:?}", arg)).collect(),
-            ),
-        }
-    }
-}
-
 impl ObservedHost {
     #[cfg(any(feature = "next", not(feature = "testutils")))]
     pub(crate) fn new(testname: &'static str, host: Host) -> Self {
@@ -653,9 +181,8 @@ impl ObservedHost {
             testname,
             host,
         };
-        oh.observe_and_check(Step::Begin);
         oh.host
-            .set_lifecycle_event_hook(Some(oh.make_obs_hook()))
+            .set_trace_hook(Some(oh.make_obs_hook()))
             .expect("installing host lifecycle hook");
         oh
     }
@@ -663,28 +190,16 @@ impl ObservedHost {
     #[cfg(all(not(feature = "next"), feature = "testutils"))]
     fn make_obs_hook(
         &self,
-    ) -> Rc<dyn for<'a> Fn(&'a Host, HostLifecycleEvent<'a>) -> Result<(), HostError>> {
+    ) -> Rc<dyn for<'a> Fn(&'a Host, TraceEvent<'a>) -> Result<(), HostError>> {
         let old_obs = self.old_obs.clone();
         let new_obs = self.new_obs.clone();
         let testname = self.testname;
         Rc::new(move |host, evt| {
-            let step = Step::from_host_lifecycle_event(evt);
-            let ob = Observation::observe(host, step);
-            Observations::check(&old_obs.borrow(), &mut new_obs.borrow_mut(), testname, ob);
+            let tr = TraceRecord::new(host, evt).expect("observing host");
+            Observations::check(&old_obs.borrow(), &mut new_obs.borrow_mut(), testname, tr);
             Ok(())
         })
     }
-
-    #[cfg(all(not(feature = "next"), feature = "testutils"))]
-    fn observe_and_check(&self, step: Step) {
-        let ob = Observation::observe(&self.host, step);
-        Observations::check(
-            &self.old_obs.borrow(),
-            &mut self.new_obs.borrow_mut(),
-            self.testname,
-            ob,
-        );
-    }
 }
 
 impl std::ops::Deref for ObservedHost {
@@ -699,8 +214,7 @@ impl std::ops::Deref for ObservedHost {
 impl Drop for ObservedHost {
     fn drop(&mut self) {
         self.host
-            .set_lifecycle_event_hook(None)
+            .set_trace_hook(None)
             .expect("resetting host lifecycle hook");
-        self.observe_and_check(Step::End)
     }
 }
diff --git a/soroban-env-host/src/testutils.rs b/soroban-env-host/src/testutils.rs
index e30feb5b..fb5b1113 100644
--- a/soroban-env-host/src/testutils.rs
+++ b/soroban-env-host/src/testutils.rs
@@ -447,7 +447,7 @@ impl Host {
         func: crate::Symbol,
         args: VecObject,
     ) -> Result<Val, HostError> {
-        use crate::{budget::AsBudget, host::HostLifecycleEvent};
+        use crate::{budget::AsBudget, host::TraceEvent};
         use soroban_bench_utils::HostTracker;
         use std::cell::RefCell;
 
@@ -459,8 +459,8 @@ impl Host {
         if std::env::var("EXCLUDE_VM_INSTANTIATION").is_ok() {
             let ht2 = ht.clone();
             let budget2 = budget.clone();
-            self.set_lifecycle_event_hook(Some(Rc::new(move |_, evt| {
-                if let HostLifecycleEvent::PushCtx(_) = evt {
+            self.set_trace_hook(Some(Rc::new(move |_, evt| {
+                if let TraceEvent::PushCtx(_) = evt {
                     budget2.reset_unlimited()?;
                     ht2.borrow_mut().start(None);
                 }
@@ -470,7 +470,7 @@ impl Host {
             ht.borrow_mut().start(None);
         }
         let val = self.call(contract, func, args);
-        self.set_lifecycle_event_hook(None)?;
+        self.set_trace_hook(None)?;
 
         let (cpu_actual, mem_actual, time_nsecs) = Rc::into_inner(ht).unwrap().into_inner().stop();