diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Revision history for railroad
 
+## 0.2.1.0 -- 2026-10-11
+* Add the tap operator `(?<*)`: runs an effect on the failure and passes the original value through
+  unchanged, so a failure can be logged or counted before a constant error throws it away:
+  `runQuery q ?<* logDbError ? err503`. The handler is a function of the failure (`CErr a -> m ()`)
+  and runs only when there is one. It needs only `Monad`, never throws, and is defined once, in
+  `Railroad.Bifurcate`, so `Railroad` and `Railroad.MonadError` re-export it.
+* Documentation: say "unwrap" instead of "peel" for taking apart one layer of a result.
+
 ## 0.2.0.1 -- 2026-10-11
 * Documentation: a README section on the empty-collection gotcha (`?` on a traversable checks that
   every element succeeds, so an empty one passes; chain `?+` before it), with the quantifiers each
diff --git a/railroad.cabal b/railroad.cabal
--- a/railroad.cabal
+++ b/railroad.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               railroad
-version:            0.2.0.1
+version:            0.2.1.0
 license:            BSD-3-Clause
 license-file:       LICENSE
 author:             Frederik Kallstrup Mastratisi
@@ -9,10 +9,52 @@
 build-type:         Simple
 tested-with:        GHC == 9.4.8, GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3, GHC == 9.12.4, GHC == 9.14.1
 extra-doc-files:    CHANGELOG.md, readme.md
-synopsis:           A railway oriented mini-DSL for unified error handling.
+synopsis:           Railway-oriented error handling: unwrap or throw in one short operator.
 description:
-    A railway oriented mini-DSL for handling and interpreting error states 
-    and cardinality checks across diverse failure types in a unified way.
+    Railway-oriented error handling for Haskell: one short operator per
+    step, and the failure paths stop taking up lines.
+
+    Each operator unwraps one layer of a result (@Maybe@, @Either@, @Bool@,
+    @Validation@, or a collection of them) and says what to throw if it
+    failed. Where you would write
+
+    > rows <- runQuery (userById uid) >>= either (const (throwError err503)) pure
+    > user <- case rows of
+    >   []  -> throwError err404
+    >   [u] -> pure u
+    >   _   -> throwError err500
+
+    you write
+
+    > user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500)
+
+    Layers are unwrapped left to right: @? err503@ unwraps the database
+    @Either@, then @?!@ demands exactly one row (none is a 404, several a 500).
+
+    * Works in any @MonadError@ ("Railroad.MonadError": @mtl@, @ExceptT@,
+      servant's @Handler@) and in @effectful@ with the @Error@ effect
+      ("Railroad").
+
+    * Unwrap or throw: @?@ (constant error) and @??@ (error computed from
+      the failure). Recover instead of throwing: @?~@ and @??~@.
+
+    * Tag by a predicate with @?>@, or fall back to another source with @?|@
+      and @?|<>@.
+
+    * Log a failure before a constant error discards it, with @?\<*@:
+      @runQuery q ?\<* logDbError ? err503@.
+
+    * @Validation@ failures accumulate: validate a whole request and report
+      every problem at once.
+
+    * All operators are @infixl 1@ like @>>=@ and @\<&\>@, so they chain without
+      parentheses.
+
+    Collections are checked with quantifiers: @?@ is ∀ ("every element
+    succeeds", so an empty collection passes), @?+@ is ∃ ("at least one"),
+    @?!@ is ∃! ("exactly one") and @?∅@ is ¬∃ ("none"). They chain:
+    @xs ?+ e1 ? e2@ means "at least one, and all succeed". The readme has the
+    details and a longer servant example.
 homepage:     https://github.com/mastratisi/railroad
 bug-reports:  https://github.com/mastratisi/railroad/issues
 source-repository head
diff --git a/readme.md b/readme.md
--- a/readme.md
+++ b/readme.md
@@ -1,6 +1,6 @@
 # railroad
 
-**A terse Haskell railroad error handling DSL abstracting over error functors by catamorphism via `Either`.**
+**A terse railway-oriented error handling DSL for Haskell. Unwrap `Maybe`, `Either`, `Bool` or `Validation`, or throw a mapped error, with one short operator.**
 
 Instead of
 
@@ -19,13 +19,13 @@
 ```
 
 Here `runQuery` returns `Eff es (Either DbError [row])`. Each operator
-peels one layer (the database failure, then "exactly one row") and says
+unwraps one layer (the database failure, then "exactly one row") and says
 what to throw if it fails, so the happy path stays one line. A longer
 [servant example](#example-a-servant-backend) is below.
 
 The purpose of `railroad` is to error handle tersely, keeping the
 happy-path clean of control-flow and functor unpacking.  It does this
-with 10 combinators, that abstract over binary co-products (`Bool`,
+with 11 combinators, that abstract over binary co-products (`Bool`,
 `Maybe`, `Either`, `Validation`) and Traversables or Folds over them.
 
 ```haskell
@@ -63,7 +63,7 @@
 - `Railroad`: the operators for the `effectful` `Error` effect.
 - `Railroad.MonadError`: the same operators for any `MonadError` (`mtl`, `ExceptT`, servant's `Handler`).
 - `Railroad.Bifurcate`: the `Bifurcate` class and the operators that never throw
-  (`?>`, `?~`, `??~`, `?|`, `?|<>`); `Railroad.Cardinality`: `CardinalityError`.
+  (`?>`, `?~`, `??~`, `?|`, `?|<>`, `?<*`); `Railroad.Cardinality`: `CardinalityError`.
   Both of the above re-export these, so you rarely import them directly.
 
 ## Install
@@ -105,11 +105,12 @@
 
 updateAsset :: Error ServerError :> es => UserId -> Asset -> Eff es ()
 updateAsset uid asset = do
+  -- permitted is an SQL exists, so it always returns exactly one row (see the gotcha below)
   _ <- runQuery (permitted uid asset) ? err500 ? err403   -- db error, then "not permitted"
   ...
 ```
 
-Each operator peels one layer, left to right: `? err503` unwraps the
+Each operator unwraps one layer, left to right: `? err503` unwraps the
 `Either`, then `?!` demands exactly one row (none is a 404, several a
 500). The same handlers in plain Haskell:
 
@@ -153,6 +154,7 @@
 | `?∅`     | Derail on non-empty collections (alias `?@`)  | `items ?∅ DuplicateFound`     |
 | `?\|`    | Fall back to another action on error         | `cache k ?\| db k ? NotFound` |
 | `?\|<>`  | Fall back, combining the errors with `<>`     | `a ?\|<> b ?? toErr`         |
+| `?<*`    | Run an effect on a failure, then carry on     | `action ?<* logIt ? MyError`  |
 
 
 All operators are `infixl 1`, like `>>=`, `<&>` and `&`, so they chain
@@ -250,6 +252,19 @@
 Neither throws by itself, and the right-hand side only runs if the left
 one failed. Unlike `Validation`, which runs all independent checks and
 collects every failure, a fallback chain succeeds on the first good answer.
+
+### Observing failures
+A constant error such as `? err503` throws the underlying failure away.
+`?<*` runs an effect on the failure first and passes the value through
+unchanged, so the next operator still sees it:
+
+```haskell
+runQuery q ?<* logDbError ? err503
+```
+
+The handler is a function of the failure (`CErr`), returns `m ()`, and
+runs only when there is a failure. Like `<*` it keeps the left value, and
+like the other operators in `Railroad.Bifurcate` it never throws.
 
 ## Gotcha: an empty collection passes
 
diff --git a/src/Railroad.hs b/src/Railroad.hs
--- a/src/Railroad.hs
+++ b/src/Railroad.hs
@@ -28,7 +28,7 @@
 (??) :: forall a es e. (Error e :> es, Bifurcate a) => Eff es a -> (CErr a -> e) -> Eff es (CRes a)
 action ?? toErr = action >>= collapse toErr
 
--- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:
+-- | Unwraps the success case, or throws a constant error. Chains to unwrap nested layers:
 -- @m ? e1 ? e2@.
 (?) :: forall es e a. (Error e :> es, Bifurcate a) => Eff es a -> e -> Eff es (CRes a)
 action ? err = action ?? const err
diff --git a/src/Railroad/Bifurcate.hs b/src/Railroad/Bifurcate.hs
--- a/src/Railroad/Bifurcate.hs
+++ b/src/Railroad/Bifurcate.hs
@@ -10,7 +10,7 @@
 -- throw. The throwing operators are in "Railroad" and "Railroad.MonadError".
 module Railroad.Bifurcate
   ( Bifurcate (..), CErr, CRes
-  , (?>), (??~), (?~), (?|), (?|<>)
+  , (?>), (??~), (?~), (?|), (?|<>), (?<*)
   ) where
 
 import           Data.Bifunctor  (first)
@@ -58,7 +58,7 @@
   -- validation :: (e -> b) -> (a -> b) -> Validation e a -> b
 
 -- The traversable instances overlap the base ones for e.g. @Either e (Maybe a)@. They are
--- INCOHERENT so the base instance wins and the outer layer is peeled first (@m ? e1 ? e2@).
+-- INCOHERENT so the base instance wins and the outer layer is unwrapped first (@m ? e1 ? e2@).
 instance {-# INCOHERENT #-} (Traversable t, CErr (t Bool) ~ (), CRes (t Bool) ~ t ())
     => Bifurcate (t Bool) where
   bifurcate = bifurcate . sequenceA . fmap bifurcate
@@ -104,4 +104,9 @@
        => m a -> m b -> m (Either (CErr a) (CRes a))
 ma ?|<> mb = ma >>= either (\ea -> first (ea <>) . bifurcate <$> mb) (pure . Right) . bifurcate
 
-infixl 1 ??~, ?~, ?|, ?|<>, ?>
+-- | Runs an effect on the failure and passes the value through unchanged. Put it before @?@
+-- or @??@ to log what they would discard: @m ?\<* logIt ? err@.
+(?<*) :: forall m a. (Monad m, Bifurcate a) => m a -> (CErr a -> m ()) -> m a
+action ?<* handler = action >>= \x -> x <$ either handler (const (pure ())) (bifurcate x)
+
+infixl 1 ??~, ?~, ?|, ?|<>, ?>, ?<*
diff --git a/src/Railroad/MonadError.hs b/src/Railroad/MonadError.hs
--- a/src/Railroad/MonadError.hs
+++ b/src/Railroad/MonadError.hs
@@ -26,7 +26,7 @@
      => m a -> (CErr a -> e) -> m (CRes a)
 action ?? toErr = action >>= collapse toErr
 
--- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:
+-- | Unwraps the success case, or throws a constant error. Chains to unwrap nested layers:
 -- @m ? e1 ? e2@.
 (?) :: forall m e a. (MonadError e m, Bifurcate a)
     => m a -> e -> m (CRes a)
diff --git a/test/Railroad/BifurcateSpec.hs b/test/Railroad/BifurcateSpec.hs
--- a/test/Railroad/BifurcateSpec.hs
+++ b/test/Railroad/BifurcateSpec.hs
@@ -35,7 +35,7 @@
       bifurcate xs === (case [e | Failure e <- xs] of
                           [] -> Right [a | Success a <- xs]
                           es -> Left (concat es))
-    it "peels only the outermost base layer" $ property $
+    it "unwraps only the outermost base layer" $ property $
       \(e :: Either Int (Maybe Int)) (m :: Maybe (Maybe Int)) (b :: Either Int Bool) ->
         (bifurcate e === e) .&&. (bifurcate m === maybe (Left ()) Right m) .&&. (bifurcate b === b)
 
@@ -47,6 +47,11 @@
     \(x :: a) (d :: CRes a) (Fn f :: Fun (CErr a) (CRes a)) ->
       (runIdentity (pure x ?~ d) === either (const d) id (bifurcate x))
       .&&. (runIdentity (pure x ??~ f) === either f id (bifurcate x))
+
+  forShapes "?<* runs the handler on a failure only and passes the value through" $ \(_ :: Proxy a) -> property $
+    \(x :: a) ->
+      let (y, logged) = runState (pure x ?<* (\e -> modify (e :))) ([] :: [CErr a])
+      in (bifurcate y === bifurcate x) .&&. (logged === either pure (const []) (bifurcate x))
 
   describe "?| (fallback, last error kept)" $ do
     let law :: forall a b. (Shape a, Shape b, CRes a ~ CRes b) => Proxy a -> Proxy b -> Property
diff --git a/test/Railroad/MonadErrorSpec.hs b/test/Railroad/MonadErrorSpec.hs
--- a/test/Railroad/MonadErrorSpec.hs
+++ b/test/Railroad/MonadErrorSpec.hs
@@ -4,6 +4,7 @@
 module Railroad.MonadErrorSpec where
 
 import           Control.Monad.Except
+import           Control.Monad.State   (modify, runState)
 import           Data.Bifunctor        (first)
 import           Data.Functor          ((<&>))
 import           Data.Functor.Identity
@@ -54,6 +55,10 @@
     it "chains the fallback with ? and ?>" $ do
       runMonadError (pure (Nothing :: Maybe Int) ?| pure (Right 9 :: Either String Int) ? "none" ?> (> 5) ? "small")
         `shouldBe` Right 9
+    it "logs a failure with ?<*, then still throws the mapped error" $ do
+      let run m = runState (runExceptT m) ([] :: [Int])
+      run (pure (Left 3 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Left "failed", [3])
+      run (pure (Right 4 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Right 4, [])
     it "gives the rejected value of ?> to the error mapper" $ do
       runMonadError (pure (4 :: Int) ?> (> 5) ?? \n -> "rejected " ++ show n) `shouldBe` Left "rejected 4"
 
diff --git a/test/RailroadSpec.hs b/test/RailroadSpec.hs
--- a/test/RailroadSpec.hs
+++ b/test/RailroadSpec.hs
@@ -7,6 +7,7 @@
 import           Data.Proxy              (Proxy (..))
 import           Effectful
 import           Effectful.Error.Dynamic
+import           Effectful.State.Static.Local (State, modify, runState)
 import           Model
 import           Railroad
 import           Test.Hspec
@@ -53,5 +54,10 @@
     it "chains the fallback with ? and ?>" $ do
       runRail (pure (Nothing :: Maybe Int) ?| pure (Right 9 :: Either String Int) ? "none" ?> (> 5) ? "small")
         `shouldBe` Right 9
+    it "logs a failure with ?<*, then still throws the mapped error" $ do
+      let run :: Eff '[Error String, State [Int]] a -> (Either String a, [Int])
+          run = runPureEff . runState [] . runErrorNoCallStack
+      run (pure (Left 3 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Left "failed", [3])
+      run (pure (Right 4 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Right 4, [])
     it "gives the rejected value of ?> to the error mapper" $ do
       runRail (pure (4 :: Int) ?> (> 5) ?? \n -> "rejected " ++ show n) `shouldBe` Left "rejected 4"
