diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,24 @@
 # Changelog for hs-opentelemetry-persistent
 
+## Unreleased
+
+## 1.0.0.0 - 2026-05-29
+
+- Internal: `db.transaction.*` attribute keys are now typed `AttributeKey` constants
+  (`db.transaction.isolation`, `db.transaction.outcome`,
+  `db.transaction.commit_duration_us`, `db.transaction.rollback_duration_us`).
+  Attribute names are unchanged.
+
+- **Respect `OTEL_SEMCONV_STABILITY_OPT_IN` for database query attribute.**
+  Query text is now emitted as `db.query.text` (stable), `db.statement` (old),
+  or both (`database/dup`), controlled by the `databaseOption` setting.
+- **Fix: span name now low-cardinality.** Previously used the full SQL string as
+  the span name, which is high cardinality and violates OTel naming rules. Now uses
+  `{db.operation.name} {db.namespace}` pattern (e.g. `SELECT mydb`). Full SQL text
+  remains available in the `db.query.text` / `db.statement` attributes.
+- Add `db.operation.name` attribute in stable mode (extracted from first SQL keyword).
+- `annotateBasics` emits `db.system.name` (stable) or `db.system` (old) per config.
+
 ## 0.1.0.2
 
 - Relax `hs-opentelemetry-api` bounds to support 0.3.x
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright Ian Duncan (c) 2021
+Copyright Ian Duncan (c) 2021-2026
 
 All rights reserved.
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,1 +1,3 @@
-# hs-opentelemetry-persistent
+# hs-opentelemetry-instrumentation-persistent
+
+[![hs-opentelemetry-instrumentation-persistent](https://img.shields.io/hackage/v/hs-opentelemetry-instrumentation-persistent?style=flat-square&logo=haskell&label=hs-opentelemetry-instrumentation-persistent&labelColor=5D4F85)](https://hackage.haskell.org/package/hs-opentelemetry-instrumentation-persistent)
diff --git a/hs-opentelemetry-instrumentation-persistent.cabal b/hs-opentelemetry-instrumentation-persistent.cabal
--- a/hs-opentelemetry-instrumentation-persistent.cabal
+++ b/hs-opentelemetry-instrumentation-persistent.cabal
@@ -1,20 +1,22 @@
 cabal-version: 1.12
 
--- This file has been generated from package.yaml by hpack version 0.37.0.
+-- This file has been generated from package.yaml by hpack version 0.38.3.
 --
 -- see: https://github.com/sol/hpack
 
-name:               hs-opentelemetry-instrumentation-persistent
-version:            0.1.0.2
-description:        Please see the README on GitHub at <https://github.com/iand675/hs-opentelemetry/tree/main/instrumentation/persistent#readme>
-homepage:           https://github.com/iand675/hs-opentelemetry#readme
-bug-reports:        https://github.com/iand675/hs-opentelemetry/issues
-author:             Ian Duncan, Jade Lovelace
-maintainer:         ian@iankduncan.com
-copyright:          2024 Ian Duncan, Mercury Technologies
-license:            BSD3
-license-file:       LICENSE
-build-type:         Simple
+name:           hs-opentelemetry-instrumentation-persistent
+version:        1.0.0.0
+synopsis:       OpenTelemetry instrumentation for the Persistent database library
+description:    Please see the README on GitHub at <https://github.com/iand675/hs-opentelemetry/tree/main/instrumentation/persistent#readme>
+category:       OpenTelemetry, Database, Telemetry
+homepage:       https://github.com/iand675/hs-opentelemetry#readme
+bug-reports:    https://github.com/iand675/hs-opentelemetry/issues
+author:         Ian Duncan, Jade Lovelace
+maintainer:     ian@iankduncan.com
+copyright:      2024 Ian Duncan, Mercury Technologies
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
 extra-source-files:
     README.md
     ChangeLog.md
@@ -32,8 +34,8 @@
       src
   build-depends:
       base >=4.7 && <5
-    , clock
-    , hs-opentelemetry-api ==0.3.*
+    , hs-opentelemetry-api ==1.0.*
+    , hs-opentelemetry-semantic-conventions >=1.40 && <2
     , mtl
     , persistent >=2.13.3
     , resourcet
@@ -41,4 +43,22 @@
     , unliftio
     , unordered-containers
     , vault
+  default-language: Haskell2010
+
+test-suite hs-opentelemetry-persistent-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_hs_opentelemetry_instrumentation_persistent
+  hs-source-dirs:
+      test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , hs-opentelemetry-api ==1.0.*
+    , hs-opentelemetry-instrumentation-persistent ==1.0.*
+    , hs-opentelemetry-semantic-conventions >=1.40 && <2
+    , hspec
+    , text
+    , unordered-containers
   default-language: Haskell2010
diff --git a/src/OpenTelemetry/Instrumentation/Persistent.hs b/src/OpenTelemetry/Instrumentation/Persistent.hs
--- a/src/OpenTelemetry/Instrumentation/Persistent.hs
+++ b/src/OpenTelemetry/Instrumentation/Persistent.hs
@@ -1,13 +1,74 @@
+{-# LANGUAGE BangPatterns #-}
 {-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE NumericUnderscores #-}
 {-# LANGUAGE OverloadedLists #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TypeApplications #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 
+{- |
+Module      : OpenTelemetry.Instrumentation.Persistent
+Copyright   : (c) Ian Duncan, 2021-2026
+License     : BSD-3
+Description : Automatic tracing for Persistent database operations
+Stability   : experimental
+
+= Overview
+
+Instruments database queries made through the @persistent@ library by
+hooking into Persistent's internal statement hooks. Every SQL query
+generates a span with the query text and connection metadata.
+
+= Quick example
+
+Wrap each 'SqlBackend' as it is handed out from the pool (here using
+extensible pool hooks; attribute maps often carry static connection info
+such as server address):
+
+@
+import Database.Persist.Postgresql (createPostgresqlPool)
+import Database.Persist.Sql
+  ( defaultSqlPoolHooks
+  , runSqlPoolWithExtensibleHooks
+  , setAlterBackend
+  )
+import OpenTelemetry.Instrumentation.Persistent (wrapSqlBackend)
+
+main :: IO ()
+main = do
+  pool <- createPostgresqlPool connStr poolSize
+  runSqlPoolWithExtensibleHooks myAction pool Nothing $
+    setAlterBackend defaultSqlPoolHooks $ \conn ->
+      wrapSqlBackend mempty conn
+@
+
+'wrapSqlBackend' uses the process-global tracer provider; initialize it from
+your application (for example 'OpenTelemetry.Trace.withTracerProvider' from
+@hs-opentelemetry-sdk@). Use 'wrapSqlBackend'' when you hold a specific
+'TracerProvider'.
+
+= What gets traced
+
+Each database operation creates a span with:
+
+* Span name: the SQL statement (truncated)
+* @db.system@, @db.statement@
+* @db.operation.name@ when detectable
+* Span kind: @Client@
+
+Note: source-location (@code.*@) attributes are intentionally not captured
+because the spans originate from Persistent's internal hooks, not from your
+application code.
+-}
 module OpenTelemetry.Instrumentation.Persistent (
   wrapSqlBackend,
   wrapSqlBackend',
+
+  -- * Span naming helpers (exported for testing)
+  extractSqlOperation,
+  dbSpanName,
+  lookupDbNamespace,
 ) where
 
 import Control.Monad
@@ -20,18 +81,23 @@
 import Data.Text (Text)
 import qualified Data.Text as T
 import qualified Data.Vault.Strict as Vault
-import Database.Persist.Sql (IsolationLevel (..), SqlReadBackend, SqlWriteBackend, Statement (..))
+import Data.Word (Word64)
+import Database.Persist.Sql (SqlReadBackend, SqlWriteBackend, Statement (..))
 import Database.Persist.SqlBackend (MkSqlBackendArgs (connRDBMS), emptySqlBackendHooks, getConnVault, getRDBMS, modifyConnVault, setConnHooks)
 import Database.Persist.SqlBackend.Internal
-import OpenTelemetry.Attributes (Attributes)
+import Database.Persist.SqlBackend.Internal.IsolationLevel (IsolationLevel (..))
+import OpenTelemetry.Attributes (Attribute (..), Attributes)
+import qualified OpenTelemetry.Attributes as A
+import OpenTelemetry.Attributes.Key (AttributeKey (..), unkey)
 import OpenTelemetry.Attributes.Map (AttributeMap)
 import OpenTelemetry.Common
 import OpenTelemetry.Context
 import OpenTelemetry.Context.ThreadLocal (adjustContext, getContext)
-import OpenTelemetry.Resource
+import OpenTelemetry.Metric.Core
+import qualified OpenTelemetry.SemanticConventions as SC
+import OpenTelemetry.SemanticsConfig
 import OpenTelemetry.Trace.Core
 import OpenTelemetry.Trace.Monad (MonadTracer (..))
-import System.Clock
 import System.IO.Unsafe (unsafePerformIO)
 import UnliftIO.Exception
 
@@ -56,6 +122,26 @@
   getTracer = lift OpenTelemetry.Trace.Monad.getTracer
 
 
+-- | @db.transaction.isolation@ – isolation level used for the transaction.
+dbTransactionIsolation :: AttributeKey Text
+dbTransactionIsolation = AttributeKey "db.transaction.isolation"
+
+
+-- | @db.transaction.outcome@ – @\"committed\"@ or @\"rolled back\"@.
+dbTransactionOutcome :: AttributeKey Text
+dbTransactionOutcome = AttributeKey "db.transaction.outcome"
+
+
+-- | @db.transaction.commit_duration_us@ – microseconds spent in the commit call.
+dbTransactionCommitDurationUs :: AttributeKey Int
+dbTransactionCommitDurationUs = AttributeKey "db.transaction.commit_duration_us"
+
+
+-- | @db.transaction.rollback_duration_us@ – microseconds spent in the rollback call.
+dbTransactionRollbackDurationUs :: AttributeKey Int
+dbTransactionRollbackDurationUs = AttributeKey "db.transaction.rollback_duration_us"
+
+
 originalConnectionKey :: Vault.Key SqlBackend
 originalConnectionKey = unsafePerformIO Vault.newKey
 {-# NOINLINE originalConnectionKey #-}
@@ -69,11 +155,6 @@
 lookupOriginalConnection = Vault.lookup originalConnectionKey . getConnVault
 
 
-connectionLevelAttributesKey :: Vault.Key AttributeMap
-connectionLevelAttributesKey = unsafePerformIO Vault.newKey
-{-# NOINLINE connectionLevelAttributesKey #-}
-
-
 {- | Wrap a 'SqlBackend' with appropriate tracing context and attributes
  so that queries are tracked appropriately in the tracing hierarchy.
 -}
@@ -107,8 +188,60 @@
   -}
   connParentSpan <- liftIO $ newIORef Nothing
   connSpanInFlight <- liftIO $ newIORef Nothing
-  -- TODO add schema to tracerOptions?
+  dbSemOpt <- liftIO $ databaseOption <$> getSemanticsOptions
+  mp <- liftIO getGlobalMeterProvider
+  meter <- liftIO $ getMeter mp "hs-opentelemetry-instrumentation-persistent"
+  dbDurHistogram <-
+    liftIO $
+      meterCreateHistogram
+        meter
+        "db.client.operation.duration"
+        (Just "s")
+        (Just "Duration of database client operations")
+        defaultAdvisoryParameters
+          { advisoryExplicitBucketBoundaries =
+              Just [0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0]
+          }
   let t = makeTracer tp $detectInstrumentationLibrary tracerOptions
+      dbNamespace = lookupDbNamespace dbSemOpt attrs
+      rdbms = getRDBMS conn
+      dbSystemAttrs = case dbSemOpt of
+        Stable -> H.fromList [(unkey SC.db_system_name, toAttribute rdbms)]
+        StableAndOld ->
+          H.fromList
+            [ (unkey SC.db_system_name, toAttribute rdbms)
+            , (unkey SC.db_system, toAttribute rdbms)
+            ]
+        Old -> H.fromList [(unkey SC.db_system, toAttribute rdbms)]
+      queryAttrs sql =
+        let v = toAttribute sql
+            opAttrs = case extractSqlOperation sql of
+              Just op -> case dbSemOpt of
+                Stable -> [(unkey SC.db_operation_name, toAttribute op)]
+                StableAndOld -> [(unkey SC.db_operation_name, toAttribute op)]
+                Old -> []
+              Nothing -> []
+        in H.union (H.fromList opAttrs) $ case dbSemOpt of
+             Stable -> H.insert (unkey SC.db_query_text) v attrs
+             StableAndOld -> H.insert (unkey SC.db_query_text) v $ H.insert (unkey SC.db_statement) v attrs
+             Old -> H.insert (unkey SC.db_statement) v attrs
+      spanName sql = dbSpanName (extractSqlOperation sql) dbNamespace
+      metricAttrs sql =
+        let base = A.addAttribute A.defaultAttributeLimits A.emptyAttributes (unkey SC.db_system_name) rdbms
+            withOp = case extractSqlOperation sql of
+              Just op -> A.addAttribute A.defaultAttributeLimits base (unkey SC.db_operation_name) op
+              Nothing -> base
+        in case dbNamespace of
+             Just ns -> A.addAttribute A.defaultAttributeLimits withOp (unkey SC.db_namespace) ns
+             Nothing -> withOp
+      recordDbDuration sql startNs = do
+        Timestamp endNs <- getTimestamp
+        let !durationSec = fromIntegral @Word64 @Double (endNs - startNs) / 1_000_000_000
+        histogramRecord dbDurHistogram durationSec (metricAttrs sql)
+  -- We use createSpanWithoutCallStack/inSpan'' because these spans are created
+  -- from persistent's internal hooks, not from user code. Using the callstack
+  -- variants would capture this instrumentation library's source location,
+  -- not the user's application code callsite.
   let hooks =
         emptySqlBackendHooks
           { hookGetStatement = \conn sql stmt -> do
@@ -117,35 +250,40 @@
                   { stmtQuery = \ps -> do
                       ctxt <- getContext
                       let spanCreator = do
+                            Timestamp sNs <- getTimestamp
                             s <-
-                              createSpan
+                              createSpanWithoutCallStack
                                 t
                                 ctxt
-                                sql
-                                (defaultSpanArguments {kind = Client, attributes = H.insert "db.statement" (toAttribute sql) attrs})
+                                (spanName sql)
+                                (defaultSpanArguments {kind = Client, attributes = queryAttrs sql})
                             adjustContext (insertSpan s)
-                            pure (lookupSpan ctxt, s)
-                          spanCleanup (parent, s) = do
+                            pure (lookupSpan ctxt, s, sNs)
+                          spanCleanup (parent, s, sNs) = do
+                            recordDbDuration sql sNs
                             s `endSpan` Nothing
                             adjustContext $ \ctx ->
                               maybe (removeSpan ctx) (`insertSpan` ctx) parent
 
-                      (p, child) <- mkAcquire spanCreator spanCleanup
+                      (_p, child, _startNs) <- mkAcquire spanCreator spanCleanup
 
-                      annotateBasics child conn
+                      addAttributes child dbSystemAttrs
                       case stmtQuery stmt ps of
                         Acquire stmtQueryAcquireF -> Acquire $ \f ->
                           handleAny
                             ( \(SomeException err) -> do
-                                recordException child [("exception.escaped", toAttribute True)] Nothing err
+                                recordException child [(unkey SC.exception_escaped, toAttribute True)] Nothing err
                                 endSpan child Nothing
                                 throwIO err
                             )
                             (stmtQueryAcquireF f)
                   , stmtExecute = \ps -> do
-                      inSpan' t sql (defaultSpanArguments {kind = Client, attributes = H.insert "db.statement" (toAttribute sql) attrs}) $ \s -> do
-                        annotateBasics s conn
+                      Timestamp startNs <- getTimestamp
+                      result <- inSpan'' t (spanName sql) (defaultSpanArguments {kind = Client, attributes = queryAttrs sql}) $ \s -> do
+                        addAttributes s dbSystemAttrs
                         stmtExecute stmt ps
+                      recordDbDuration sql startNs
+                      pure result
                   , stmtReset = stmtReset stmt
                   , stmtFinalize = stmtFinalize stmt
                   }
@@ -156,36 +294,38 @@
           { connHooks = hooks
           , connBegin = \f mIso -> do
               ctxt <- getContext
-              s <- createSpan t ctxt "transaction" (defaultSpanArguments {kind = Client, attributes = attrs})
-              annotateBasics s conn
+              s <- createSpanWithoutCallStack t ctxt (dbSpanName (Just "TRANSACTION") dbNamespace) (defaultSpanArguments {kind = Client, attributes = attrs})
+              let isoAttrs = case mIso of
+                    Nothing -> H.empty
+                    Just iso ->
+                      H.singleton (unkey dbTransactionIsolation) $ toAttribute $ case iso of
+                        ReadUncommitted -> "read uncommitted" :: Text
+                        ReadCommitted -> "read committed"
+                        RepeatableRead -> "repeatable read"
+                        Serializable -> "serializable"
+              addAttributes s (dbSystemAttrs `H.union` isoAttrs)
               writeIORef connSpanInFlight (Just s)
               writeIORef connParentSpan (lookupSpan ctxt)
               adjustContext (insertSpan s)
-              case mIso of
-                Nothing -> pure ()
-                Just iso -> addAttribute s "db.transaction.isolation" $ case iso of
-                  ReadUncommitted -> "read uncommitted" :: Text
-                  ReadCommitted -> "read committed"
-                  RepeatableRead -> "repeatable read"
-                  Serializable -> "serializable"
               connBegin conn f mIso
           , connCommit = \f -> do
               spanInFlight <- readIORef connSpanInFlight
               parentSpan <- readIORef connParentSpan
               let act = do
-                    (Timestamp tsStart) <- getTimestamp
+                    Timestamp nsStart <- getTimestamp
                     result <- tryAny $ connCommit conn f
-                    (Timestamp tsEnd) <- getTimestamp
+                    Timestamp nsEnd <- getTimestamp
                     forM_ spanInFlight $ \s -> do
+                      let !durationMicros = fromIntegral @Word64 @Int ((nsEnd - nsStart) `div` 1000)
                       addAttributes
                         s
-                        [ ("db.transaction.outcome", toAttribute ("committed" :: Text))
-                        , ("db.transaction.commit_duration_ns", toAttribute $ fromIntegral @Integer @Int $ toNanoSecs (diffTimeSpec tsStart tsEnd) `div` 1000)
+                        [ (unkey dbTransactionOutcome, toAttribute ("committed" :: Text))
+                        , (unkey dbTransactionCommitDurationUs, toAttribute durationMicros)
                         ]
                       endSpan s Nothing
                       case result of
                         Left (SomeException err) -> do
-                          recordException s [("exception.escaped", toAttribute True)] Nothing err
+                          recordException s [(unkey SC.exception_escaped, toAttribute True)] Nothing err
                           throwIO err
                         Right _ -> pure ()
               act `finally` do
@@ -196,37 +336,62 @@
               spanInFlight <- readIORef connSpanInFlight
               parentSpan <- readIORef connParentSpan
               let act = do
-                    (Timestamp tsStart) <- getTimestamp
+                    Timestamp nsStart <- getTimestamp
                     result <- tryAny $ connRollback conn f
-                    e@(Timestamp tsEnd) <- getTimestamp
+                    Timestamp nsEnd <- getTimestamp
                     forM_ spanInFlight $ \s -> do
+                      let !durationMicros = fromIntegral @Word64 @Int ((nsEnd - nsStart) `div` 1000)
                       addAttributes
                         s
-                        [ ("db.transaction.outcome", toAttribute ("rolled back" :: Text))
-                        , ("db.transaction.commit_duration_microseconds", toAttribute $ fromIntegral @Integer @Int $ toNanoSecs (diffTimeSpec tsStart tsEnd `div` 1000))
+                        [ (unkey dbTransactionOutcome, toAttribute ("rolled back" :: Text))
+                        , (unkey dbTransactionRollbackDurationUs, toAttribute durationMicros)
                         ]
-                      endSpan s (Just e)
+                      endSpan s Nothing
                       case result of
                         Left (SomeException err) -> do
-                          recordException s [("exception.escaped", toAttribute True)] Nothing err
+                          recordException s [(unkey SC.exception_escaped, toAttribute True)] Nothing err
                           throwIO err
                         Right _ -> pure ()
               act `finally` do
                 adjustContext $ \ctx ->
                   maybe (removeSpan ctx) (`insertSpan` ctx) parentSpan
                 forM_ spanInFlight $ \s -> endSpan s Nothing
-          , -- TODO: This doesn't work when we wrap the connections for the pool.
+          , -- Known limitation: connClose spans are not emitted when
+            -- persistent's connection pool wraps the underlying connection,
+            -- since the pool manages connection lifecycle independently.
             connClose = do
-              inSpan' t "close connection" (defaultSpanArguments {kind = Client, attributes = attrs}) $ \s -> do
-                annotateBasics s conn
+              inSpan'' t (dbSpanName (Just "CLOSE") dbNamespace) (defaultSpanArguments {kind = Client, attributes = attrs}) $ \s -> do
+                addAttributes s dbSystemAttrs
                 connClose conn
           }
   pure $ insertOriginalConnection conn' conn
 
 
-annotateBasics :: (MonadIO m) => Span -> SqlBackend -> m ()
-annotateBasics span conn = do
-  addAttributes
-    span
-    [ ("db.system", toAttribute $ getRDBMS conn)
-    ]
+extractSqlOperation :: Text -> Maybe Text
+extractSqlOperation sql =
+  let trimmed = T.dropWhile (\c -> c == ' ' || c == '\n' || c == '\r' || c == '\t') sql
+      keyword = T.takeWhile (\c -> c /= ' ' && c /= '\n' && c /= '\r' && c /= '\t' && c /= '(') trimmed
+  in if T.null keyword
+       then Nothing
+       else Just $ T.toUpper keyword
+
+
+lookupDbNamespace :: StabilityOpt -> AttributeMap -> Maybe Text
+lookupDbNamespace opt attrMap =
+  let tryKey k = case H.lookup k attrMap of
+        Just (AttributeValue (TextAttribute v)) -> Just v
+        _ -> Nothing
+  in case opt of
+       Stable -> tryKey (unkey SC.db_namespace)
+       StableAndOld -> tryKey (unkey SC.db_namespace) <|> tryKey (unkey SC.db_name)
+       Old -> tryKey (unkey SC.db_name)
+  where
+    Nothing <|> b = b
+    a <|> _ = a
+
+
+dbSpanName :: Maybe Text -> Maybe Text -> Text
+dbSpanName (Just op) (Just ns) = op <> " " <> ns
+dbSpanName (Just op) Nothing = op
+dbSpanName Nothing (Just ns) = ns
+dbSpanName Nothing Nothing = "DB"
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,100 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import qualified Data.HashMap.Strict as H
+import OpenTelemetry.Attributes (Attribute (..))
+import OpenTelemetry.Attributes.Attribute (PrimitiveAttribute (..))
+import OpenTelemetry.Attributes.Key (unkey)
+import OpenTelemetry.Instrumentation.Persistent
+import qualified OpenTelemetry.SemanticConventions as SC
+import OpenTelemetry.SemanticsConfig (StabilityOpt (..))
+import Test.Hspec
+
+
+main :: IO ()
+main = hspec spec
+
+
+spec :: Spec
+spec = do
+  describe "extractSqlOperation" $ do
+    it "extracts SELECT" $
+      extractSqlOperation "SELECT * FROM users" `shouldBe` Just "SELECT"
+
+    it "extracts INSERT" $
+      extractSqlOperation "INSERT INTO users (name) VALUES (?)" `shouldBe` Just "INSERT"
+
+    it "extracts UPDATE" $
+      extractSqlOperation "UPDATE users SET name = ? WHERE id = ?" `shouldBe` Just "UPDATE"
+
+    it "extracts DELETE" $
+      extractSqlOperation "DELETE FROM users WHERE id = ?" `shouldBe` Just "DELETE"
+
+    it "handles leading whitespace" $
+      extractSqlOperation "  \n\t SELECT 1" `shouldBe` Just "SELECT"
+
+    it "returns Nothing for bare parenthesized subquery" $
+      extractSqlOperation "(SELECT 1)" `shouldBe` Nothing
+
+    it "uppercases mixed-case keywords" $
+      extractSqlOperation "select * from t" `shouldBe` Just "SELECT"
+
+    it "returns Nothing for empty string" $
+      extractSqlOperation "" `shouldBe` Nothing
+
+    it "returns Nothing for whitespace only" $
+      extractSqlOperation "   \n\t  " `shouldBe` Nothing
+
+    it "extracts WITH (CTE)" $
+      extractSqlOperation "WITH cte AS (SELECT 1) SELECT * FROM cte" `shouldBe` Just "WITH"
+
+    it "extracts BEGIN" $
+      extractSqlOperation "BEGIN" `shouldBe` Just "BEGIN"
+
+    it "extracts COMMIT" $
+      extractSqlOperation "COMMIT" `shouldBe` Just "COMMIT"
+
+  describe "dbSpanName" $ do
+    it "combines operation and namespace" $
+      dbSpanName (Just "SELECT") (Just "mydb") `shouldBe` "SELECT mydb"
+
+    it "uses operation alone when no namespace" $
+      dbSpanName (Just "INSERT") Nothing `shouldBe` "INSERT"
+
+    it "uses namespace alone when no operation" $
+      dbSpanName Nothing (Just "mydb") `shouldBe` "mydb"
+
+    it "falls back to DB when both missing" $
+      dbSpanName Nothing Nothing `shouldBe` "DB"
+
+  describe "lookupDbNamespace" $ do
+    let stableAttrs = H.fromList [(unkey SC.db_namespace, AttributeValue (TextAttribute "stabledb"))]
+        oldAttrs = H.fromList [(unkey SC.db_name, AttributeValue (TextAttribute "olddb"))]
+        bothAttrs =
+          H.fromList
+            [ (unkey SC.db_namespace, AttributeValue (TextAttribute "stabledb"))
+            , (unkey SC.db_name, AttributeValue (TextAttribute "olddb"))
+            ]
+        emptyAttrs = H.empty
+
+    it "reads db.namespace in Stable mode" $
+      lookupDbNamespace Stable stableAttrs `shouldBe` Just "stabledb"
+
+    it "returns Nothing for db.name in Stable mode" $
+      lookupDbNamespace Stable oldAttrs `shouldBe` Nothing
+
+    it "reads db.namespace in StableAndOld mode (prefers stable)" $
+      lookupDbNamespace StableAndOld bothAttrs `shouldBe` Just "stabledb"
+
+    it "falls back to db.name in StableAndOld mode" $
+      lookupDbNamespace StableAndOld oldAttrs `shouldBe` Just "olddb"
+
+    it "reads db.name in Old mode" $
+      lookupDbNamespace Old oldAttrs `shouldBe` Just "olddb"
+
+    it "returns Nothing for db.namespace in Old mode" $
+      lookupDbNamespace Old stableAttrs `shouldBe` Nothing
+
+    it "returns Nothing when attrs empty" $
+      lookupDbNamespace Stable emptyAttrs `shouldBe` Nothing
