packages feed

effectful-tracing-0.1.0.0: src/Effectful/Tracing/Instrumentation/Database.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Instrumentation.Database
-- Description : Framework-agnostic helpers for tracing database client calls.
-- Copyright   : (c) The effectful-tracing contributors
-- License     : BSD-3-Clause
-- Stability   : experimental
--
-- A small, dependency-free core for wrapping a database call in a @client@-kind
-- span that records the stable OpenTelemetry database semantic conventions (see
-- "Effectful.Tracing.SemConv"). It knows nothing about any particular driver:
-- you describe the call with a 'DatabaseQuery' and run the action inside
-- 'withQuerySpan'. The driver-specific module
-- "Effectful.Tracing.Instrumentation.PostgresqlSimple" (built with the
-- @postgresql-simple@ flag) is a thin layer on top of this.
--
-- > runUsers :: (IOE :> es, Tracer :> es) => Connection -> Eff es [User]
-- > runUsers conn =
-- >   withQuerySpan
-- >     (databaseQuery "postgresql")
-- >       { queryText = Just "SELECT id, name FROM users WHERE active = $1"
-- >       , queryOperation = Just "SELECT"
-- >       , queryCollection = Just "users"
-- >       }
-- >     (liftIO (query conn "SELECT id, name FROM users WHERE active = ?" (Only True)))
--
-- The span is named following the convention @{operation} {collection}@ (for
-- example @\"SELECT users\"@), falling back to the operation alone, then to the
-- system name, so the name stays low cardinality. Record the /parameterized/
-- statement in 'queryText' (placeholders, not interpolated values) to avoid
-- leaking row data; 'inferOperationName' can pull the leading command keyword
-- out of such a statement when the driver does not give you one directly.
module Effectful.Tracing.Instrumentation.Database
  ( -- * Describing a query
    DatabaseQuery (..)
  , databaseQuery

    -- * Tracing a query
  , withQuerySpan

    -- * Helpers
  , inferOperationName
  , querySpanName
  , queryAttributes
  ) where

import Data.Char (isSpace)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Stack (HasCallStack)

import Effectful (Eff, (:>))

import Effectful.Tracing
  ( SpanArguments (attributes, kind)
  , SpanKind (Client)
  , Tracer
  , defaultSpanArguments
  , withSpan'
  , (.=)
  )
import Effectful.Tracing.Attribute (Attribute)
import Effectful.Tracing.SemConv qualified as SemConv

-- | A driver-agnostic description of a database call, used to populate the
-- @db.*@ attributes (see "Effectful.Tracing.SemConv") and the span name. Build
-- it with 'databaseQuery' and fill in the fields you know; every optional field
-- left as 'Nothing' is simply not recorded.
data DatabaseQuery = DatabaseQuery
  { querySystem :: !Text
  -- ^ @db.system.name@: the DBMS, for example @\"postgresql\"@. Required.
  , queryText :: !(Maybe Text)
  -- ^ @db.query.text@: the /parameterized/ statement (placeholders, not
  -- interpolated values), for example @\"SELECT * FROM users WHERE id = $1\"@.
  , queryOperation :: !(Maybe Text)
  -- ^ @db.operation.name@: the low-cardinality command keyword, for example
  -- @\"SELECT\"@. 'inferOperationName' can derive this from 'queryText'.
  , queryCollection :: !(Maybe Text)
  -- ^ @db.collection.name@: the primary table the call acts on, for example
  -- @\"users\"@.
  , queryNamespace :: !(Maybe Text)
  -- ^ @db.namespace@: the logical database the connection is scoped to.
  }
  deriving (Eq, Show)

-- | A 'DatabaseQuery' for the given @db.system.name@ with every optional field
-- unset, ready for record-update syntax to fill in what you know.
--
-- > (databaseQuery "postgresql") { queryOperation = Just "INSERT", queryCollection = Just "orders" }
databaseQuery :: Text -> DatabaseQuery
databaseQuery system =
  DatabaseQuery
    { querySystem = system
    , queryText = Nothing
    , queryOperation = Nothing
    , queryCollection = Nothing
    , queryNamespace = Nothing
    }

-- | Run a database action inside a @client@-kind span named by 'querySpanName'
-- and annotated with 'queryAttributes'. The span is finalized (with its end
-- time, and 'Effectful.Tracing.Error' status if the action throws) by the
-- shared span lifecycle when the action returns or unwinds.
withQuerySpan
  :: (HasCallStack, Tracer :> es)
  => DatabaseQuery
  -> Eff es a
  -> Eff es a
withQuerySpan q =
  withSpan'
    (querySpanName q)
    defaultSpanArguments
      { kind = Client
      , attributes = queryAttributes q
      }

-- | The span name for a query, following the OpenTelemetry convention: prefer
-- @{operation} {collection}@ (for example @\"SELECT users\"@), fall back to the
-- operation alone, then to the @db.system.name@ when neither is known. This
-- keeps the name low cardinality (never the full statement).
querySpanName :: DatabaseQuery -> Text
querySpanName q =
  case (queryOperation q, queryCollection q) of
    (Just op, Just coll) -> op <> " " <> coll
    (Just op, Nothing) -> op
    (Nothing, _) -> querySystem q

-- | The @db.*@ attributes for a query: @db.system.name@ always, plus
-- @db.query.text@, @db.operation.name@, @db.collection.name@, and
-- @db.namespace@ for whichever optional fields are set.
queryAttributes :: DatabaseQuery -> [Attribute]
queryAttributes q =
  (SemConv.dbSystemName .= querySystem q)
    : mapMaybe
      optional
      [ (SemConv.dbQueryText, queryText q)
      , (SemConv.dbOperationName, queryOperation q)
      , (SemConv.dbCollectionName, queryCollection q)
      , (SemConv.dbNamespace, queryNamespace q)
      ]
  where
    optional (key, value) = (key .=) <$> value

-- | Pull the low-cardinality operation name out of a statement: the leading
-- word, upper-cased, for example @\"SELECT\"@ from
-- @\"select * from users\"@. Returns 'Nothing' for a blank statement. This is a
-- best-effort heuristic for drivers that do not hand you the operation
-- directly; it does not parse SQL.
inferOperationName :: Text -> Maybe Text
inferOperationName statement =
  case T.words (T.dropWhile isSpace statement) of
    [] -> Nothing
    (w : _) -> Just (T.toUpper w)