diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for auto-instrument
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Aaron Allen
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Aaron Allen nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,118 @@
+# Open Telemetry Auto Instrumentation
+
+This is a GHC plugin for automatically instrumenting a Haskell application with
+open telemetry spans based on user configuration. The instrumentation
+functionality is provided by
+[`hs-opentelemetry`](https://github.com/iand675/hs-opentelemetry).
+
+- [Quick start](#quick-start)
+- [Configuration](#configuration)
+  - [Config structure](#config-structure)
+  - [Example config](#example-config)
+- [Known pitfalls](#known-pitfalls)
+
+### Quick start
+
+- Add this package as a project dependency
+- Create a file called `auto-instrument-config.toml` in the project root directory:
+  ```toml
+  [[targets]]
+  type = "constructor"
+  value = "MyAppMonad"
+  ```
+  Replace `MyAppMonad` with your application's primary monad. This monad needs
+  to have an instance for `MonadUnliftIO`, otherwise you'll get a type error.
+- Initialize the global tracer provider as part of application startup. The
+  plugin will not insert spans until after the gobal tracer provider has been
+  initialized. See the
+  [`hs-opentelemetry-sdk` documentation](https://hackage.haskell.org/package/hs-opentelemetry-sdk)
+  for instructions.
+- Pass the `-fplugin AutoInstrument` argument to GHC when compiling the project.
+  This can be done project-wide in the `*.cabal` or `package.yaml` file using
+  `ghc-options: -fplugin AutoInstrument`, or by adding
+  `{- OPTIONS_GHC -fplugin AutoInstrument -}` to individual modules.
+- Only top-level functions that have type signatures with a return type that
+  matches the target monad will be instrumented.
+
+### Configuration
+
+Configuration is supplied by a user defined TOML file that declares a set of
+rules used to determine which functions should be instrumented. The plugin will
+only consider top level functions that have type signatures when matching
+against these rules. By default the plugin looks for a config file called
+`auto-instrument-config.toml` in the project root. You can change this by
+passing a config file path as a plugin option, for example: `-fplugin
+AutoInstrument -fplugin-opt AutoInstrument:my-config.toml`.
+
+#### Config structure
+
+- The `targets` key is an array of tables that specify how to identify a function
+  to instrument based on its type signature.
+- These tables have a `type` field that can either `"constructor"` or `"constraints"`
+  and a `value` key with the value corresponding to the chosen `type`.
+  - `"constructor"` is used to target the return type of the function. This will
+    typically be your application's monad. It is not necessary to provide all
+    arguments to this type and arguments that should be ignored can replaced with
+    an underscore.
+  - `"constraints"` allows for a set of constraints to be specified which must
+    all be present in the constraint context of a function in order for it to
+    be instrumented. The `value` field should be an array of constraint types
+    which do not need to be fully applied and can have underscore wildcards.
+- The `exclusions` key is an array with the same structure as `targets`. If any
+  of these rules match a type signature, the corresponding declaration(s) will
+  not be instrumented.
+
+#### Example config
+
+```toml
+# Targets are things that should be auto instrumented for tracing.
+# "constructor" means that it should match the return type of the function
+# while "constraints" means that all the constraints in the "value" array must
+# be present in the constraint context of the function.
+
+[[targets]]
+type = "constructor"
+value = "AppMonad"
+
+[[targets]]
+type = "constraints"
+value = ["MonadUnliftIO"]
+
+# Exclusions denote types that should not be instrumented. This is primarily
+# needed for when a target constraint appears in a definition's context but
+# doesn't apply directly to the return type, for example:
+# server :: MonadUnliftIO m => ServerT Api m
+
+[[exclusions]]
+type = "constructor"
+value = "ServerT"
+
+[[exclusions]]
+type = "constructor"
+value = "ConduitT"
+```
+
+### Known pitfalls
+
+Functions that loop can be problematic when instrumented if a new span is
+entered for each iteration. For example, if an application has a process that
+continually performs some polling action in a loop, then instrumenting that
+process would result in a space leak due to the mass of nested spans being
+allocated and retained on the heap. One way for dealing with this is to define
+a type synonym `type NotInstrumented a = a`, add an exclusion rule for it to
+the config, and apply it to the result type of any such looping functions:
+
+```haskell
+type NotInstrumented a = a
+
+loop :: NotInstrumented (MyApp ())
+loop = do
+  ...
+  loop
+```
+---
+```toml
+[[exclusions]]
+type = "constructor"
+value = "NotInstrumented"
+```
diff --git a/hs-opentelemetry-instrumentation-auto.cabal b/hs-opentelemetry-instrumentation-auto.cabal
new file mode 100644
--- /dev/null
+++ b/hs-opentelemetry-instrumentation-auto.cabal
@@ -0,0 +1,72 @@
+cabal-version:      3.0
+name:               hs-opentelemetry-instrumentation-auto
+version:            0.1.0.0
+synopsis:           Plugin for instrumenting an application
+description:        A GHC plugin that auto-instruments an application for emitting open telementry tracing.
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Aaron Allen
+maintainer:         aaronallen8455@gmail.com
+-- copyright:
+category:           Development
+build-type:         Simple
+extra-doc-files:
+  CHANGELOG.md
+  README.md
+extra-source-files: test-config.toml
+tested-with:
+  GHC == 9.4.8
+  GHC == 9.6.2
+
+common warnings
+    ghc-options: -Wall
+
+library
+    import:           warnings
+    exposed-modules:
+      AutoInstrument
+      AutoInstrument.Internal.Plugin
+      AutoInstrument.Internal.Plugin.Parser
+      AutoInstrument.Internal.Config
+      AutoInstrument.Internal.GhcFacade
+      AutoInstrument.Internal.Types
+    -- other-modules:
+    -- other-extensions:
+    build-depends:
+      base >=4.17.0.0 && <4.20.0.0,
+      ghc >=9.4.0 && <9.9.0,
+      bytestring ^>= 0.11 || ^>= 0.12,
+      directory ^>= 1.3,
+      containers ^>= 0.6,
+      unliftio ^>= 0.2,
+      hs-opentelemetry-api ^>= 0.0.3 || ^>= 0.1,
+      text ^>= 2.0,
+      toml-parser >= 2.0.0.0 && < 3.0.0.0,
+      parsec ^>= 3.1,
+      time ^>= 1.12
+    hs-source-dirs:   src
+    default-language: GHC2021
+
+test-suite auto-instrument-test
+    import:           warnings
+    default-language: GHC2021
+    -- other-modules:
+    -- other-extensions:
+    type:             exitcode-stdio-1.0
+    hs-source-dirs:   test
+    main-is:          Main.hs
+    build-depends:
+      base >=4.17.0.0 && <4.20.0.0,
+      unordered-containers,
+      hs-opentelemetry-instrumentation-auto,
+      hs-opentelemetry-exporter-in-memory,
+      hs-opentelemetry-api,
+      hs-opentelemetry-sdk,
+      tasty-hunit,
+      tasty,
+      unliftio,
+      text
+    ghc-options:
+      -threaded
+      -fplugin AutoInstrument
+      -fplugin-opt AutoInstrument:test-config.toml
diff --git a/src/AutoInstrument.hs b/src/AutoInstrument.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument.hs
@@ -0,0 +1,5 @@
+module AutoInstrument
+  ( plugin
+  ) where
+
+import           AutoInstrument.Internal.Plugin
diff --git a/src/AutoInstrument/Internal/Config.hs b/src/AutoInstrument/Internal/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument/Internal/Config.hs
@@ -0,0 +1,193 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE CPP #-}
+module AutoInstrument.Internal.Config
+  ( Config(..)
+  , ConfigCache(..)
+  , Target(..)
+  , getConfigCache
+  , getConfigFilePath
+  , defaultConfigFile
+  , TargetCon(..)
+  , ConstraintSet
+  ) where
+
+import           Control.Applicative ((<|>))
+import           Control.Concurrent.MVar
+import           Data.IORef
+import           Data.Maybe
+import           Data.Set (Set)
+import qualified Data.Set as S
+import qualified Data.Text.IO as T
+import           Data.Time
+import qualified System.Directory as Dir
+import           System.IO.Unsafe (unsafePerformIO)
+import qualified Text.Parsec as P
+import qualified Text.Parsec.Error as P
+import qualified Text.Parsec.String as P
+import qualified Toml.Schema.FromValue as Toml
+import qualified Toml
+
+import qualified AutoInstrument.Internal.GhcFacade as Ghc
+
+data ConfigCache = MkConfigCache
+  { timestamp :: !UTCTime
+  , getConfig :: !Config
+  , fingerprint :: !Ghc.Fingerprint
+  }
+
+configCache :: IORef (Maybe ConfigCache)
+configCache = unsafePerformIO $ newIORef Nothing
+{-# NOINLINE configCache #-}
+
+-- | Used to ensure that the config file is only read by one thread when the
+-- cache expires or needs to be initialized.
+semaphore :: MVar ()
+semaphore = unsafePerformIO $ newMVar ()
+{-# NOINLINE semaphore #-}
+
+-- Cache expires after 20 seconds
+cacheDuration :: NominalDiffTime
+cacheDuration = 20
+
+data Config = MkConfig
+  { targets :: [Target]
+  , exclusions :: [Target]
+  }
+
+data Target
+  = Constructor TargetCon
+  | Constraints ConstraintSet
+
+data TargetCon
+  = TyVar String
+  | WC
+  | App TargetCon TargetCon
+  | Unit
+  | Tuple [TargetCon]
+  deriving (Show, Eq, Ord)
+
+skipSpaces :: P.Parser ()
+skipSpaces = P.skipMany P.space
+
+targetParser :: P.Parser TargetCon
+targetParser = appP
+  where
+    appP = P.chainl1 (P.try unitP <|> varP <|> parenP) (pure App) <* skipSpaces
+    unitP = Unit <$ P.string "()" <* skipSpaces
+    varP = do
+      v <- P.many1 (P.satisfy $ \c -> c `notElem` [' ', '(', ')', ',']) <* skipSpaces
+      case v of
+        "_" -> pure WC
+        _ -> pure $ TyVar v
+    parenP = do
+      inParens <-
+        P.between (P.char '(' <* skipSpaces) (P.char ')')
+          (P.sepBy1 targetParser (P.char ',' <* skipSpaces))
+          <* skipSpaces
+      case inParens of
+        [t] -> pure t
+        _ -> pure $ Tuple inParens
+
+type ConstraintSet = Set TargetCon
+
+instance Toml.FromValue Config where
+  fromValue = Toml.parseTableFromValue $
+    MkConfig
+      <$> Toml.reqKey "targets"
+      <*> (fromMaybe [] <$> Toml.optKey "exclusions")
+
+instance Toml.FromValue Target where
+  fromValue = Toml.parseTableFromValue $ do
+    tag <- Toml.reqKey "type"
+    case tag of
+      "constructor" -> do
+        value <- Toml.reqKey "value"
+        case P.parse (skipSpaces *> targetParser <* P.eof) "" value of
+          Right target -> pure $ Constructor target
+          Left err -> fail $ showParsecError err
+      "constraints" -> do
+        value <- Toml.reqKey "value"
+        let parsePred v =
+              case P.parse (skipSpaces *> targetParser <* P.eof) "" v of
+                Right target -> pure target
+                Left err -> fail $ showParsecError err
+        Constraints . S.fromList <$> traverse parsePred value
+      _ -> fail $ "Unrecognized targed type: " <> tag
+
+-- | Doesn't show the source location
+showParsecError :: P.ParseError -> String
+showParsecError
+  = drop 1
+  . P.showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input"
+  . P.errorMessages
+
+getConfigCache :: [Ghc.CommandLineOption] -> IO (Maybe ConfigCache)
+getConfigCache opts = do
+  mCached <- readIORef configCache
+  case mCached of
+    Nothing -> getConfigOrRefresh opts
+    Just cached -> do
+      expired <- isCacheExpired cached
+      if expired
+      then getConfigOrRefresh opts
+      else pure $ Just cached
+
+-- | This blocks on the MVar to ensure that the file is only read by one thread when necessary.
+-- contention for the MVar only occurs when the cache expires or hasn't been initialized.
+getConfigOrRefresh :: [Ghc.CommandLineOption] -> IO (Maybe ConfigCache)
+getConfigOrRefresh opts = do
+  withMVar semaphore $ \_ -> do
+    mCached <- readIORef configCache
+    case mCached of
+      Nothing -> refreshConfigCache opts
+      Just existing -> do
+        expired <- isCacheExpired existing
+        if expired
+        then refreshConfigCache opts
+        else pure $ Just existing
+
+isCacheExpired :: ConfigCache -> IO Bool
+isCacheExpired cached = do
+  now <- getCurrentTime
+  let diff = diffUTCTime now $ timestamp cached
+  pure $ diff >= cacheDuration
+
+refreshConfigCache :: [Ghc.CommandLineOption] -> IO (Maybe ConfigCache)
+refreshConfigCache opts = do
+  newCache <- mkConfigCache opts
+  writeIORef configCache newCache
+  pure newCache
+
+mkConfigCache :: [Ghc.CommandLineOption] -> IO (Maybe ConfigCache)
+mkConfigCache opts = do
+  let cfgFile = getConfigFilePath opts
+  exists <- Dir.doesFileExist cfgFile
+  if exists
+     then do
+       result <- Toml.decode <$> T.readFile cfgFile
+       case result of
+         Toml.Success _ config -> do
+           now <- getCurrentTime
+           fp <- Ghc.getFileHash cfgFile
+           pure $ Just MkConfigCache
+             { timestamp = now
+             , getConfig = config
+             , fingerprint = fp
+             }
+         Toml.Failure errs -> do
+           putStr $ unlines
+            $ "================================================================================"
+            : "Failed to parse auto instrument config file:"
+            : errs
+            ++ ["================================================================================"]
+           pure Nothing
+     else do
+       pure Nothing
+
+getConfigFilePath :: [Ghc.CommandLineOption] -> FilePath
+getConfigFilePath (opt : _) = opt
+getConfigFilePath [] = defaultConfigFile
+
+defaultConfigFile :: FilePath
+defaultConfigFile = "auto-instrument-config.toml"
diff --git a/src/AutoInstrument/Internal/GhcFacade.hs b/src/AutoInstrument/Internal/GhcFacade.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument/Internal/GhcFacade.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+module AutoInstrument.Internal.GhcFacade
+  ( module Ghc
+  , mkParseError
+  ) where
+
+#if MIN_VERSION_ghc(9,6,0)
+import           GHC.Plugins as Ghc hiding (getHscEnv, putMsg, fatalErrorMsg, errorMsg, debugTraceMsg)
+import           GHC.Fingerprint as Ghc
+import           GHC.Iface.Env as Ghc
+import           GHC.Unit.Finder as Ghc
+import           GHC.Driver.Main as Ghc
+import           Language.Haskell.Syntax as Ghc
+import           GHC.Hs as Ghc (HsParsedModule(..))
+import           GHC.Hs.Extension as Ghc
+import           GHC.Parser.Annotation as Ghc (SrcSpanAnn'(..), SrcSpanAnnA, noAnn, noSrcSpanA, realSrcSpan)
+import           GHC.Parser.Errors.Types as Ghc
+import           GHC.Types.SourceText as Ghc
+import           GHC.Types.Error as Ghc
+import           GHC.Utils.Error as Ghc
+#elif MIN_VERSION_ghc(9,4,0)
+import           GHC.Plugins as Ghc hiding (getHscEnv, putMsg, fatalErrorMsg, errorMsg, debugTraceMsg)
+import           GHC.Fingerprint as Ghc
+import           GHC.Iface.Env as Ghc
+import           GHC.Unit.Finder as Ghc
+import           GHC.Driver.Main as Ghc
+import           Language.Haskell.Syntax as Ghc
+import           GHC.Hs as Ghc (HsParsedModule(..), HsModule(..))
+import           GHC.Hs.Extension as Ghc
+import           GHC.Parser.Annotation as Ghc (SrcSpanAnn'(..), SrcSpanAnnA, noAnn, noSrcSpanA, realSrcSpan)
+import           GHC.Parser.Errors.Types as Ghc
+import           GHC.Types.SourceText as Ghc
+import           GHC.Types.Error as Ghc
+import           GHC.Utils.Error as Ghc
+#endif
+
+mkParseError :: String -> MsgEnvelope PsMessage
+mkParseError
+  = Ghc.mkPlainErrorMsgEnvelope (Ghc.mkGeneralSrcSpan "plugin")
+  . Ghc.PsUnknownMessage
+#if MIN_VERSION_ghc (9,8,0)
+  . Ghc.mkUnknownDiagnostic
+#elif MIN_VERSION_ghc (9,6,0)
+  . Ghc.UnknownDiagnostic
+#endif
+  . Ghc.mkPlainError Ghc.noHints
+  . Ghc.text
diff --git a/src/AutoInstrument/Internal/Plugin.hs b/src/AutoInstrument/Internal/Plugin.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument/Internal/Plugin.hs
@@ -0,0 +1,21 @@
+module AutoInstrument.Internal.Plugin
+  ( plugin
+  ) where
+
+import qualified AutoInstrument.Internal.Config as Cfg
+import qualified AutoInstrument.Internal.GhcFacade as Ghc
+import qualified AutoInstrument.Internal.Plugin.Parser as Parser
+
+plugin :: Ghc.Plugin
+plugin = Ghc.defaultPlugin
+  { Ghc.pluginRecompile = pluginRecompile
+  , Ghc.parsedResultAction = Parser.parsedResultAction
+  }
+
+pluginRecompile :: [Ghc.CommandLineOption] -> IO Ghc.PluginRecompile
+pluginRecompile opts = do
+  mCache <- Cfg.getConfigCache opts
+  case mCache of
+    Nothing -> pure Ghc.NoForceRecompile
+    Just cache ->
+     pure . Ghc.MaybeRecompile $ Cfg.fingerprint cache
diff --git a/src/AutoInstrument/Internal/Plugin/Parser.hs b/src/AutoInstrument/Internal/Plugin/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument/Internal/Plugin/Parser.hs
@@ -0,0 +1,178 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE LambdaCase #-}
+module AutoInstrument.Internal.Plugin.Parser
+  ( parsedResultAction
+  ) where
+
+import           Control.Monad.IO.Class (liftIO)
+import qualified Data.ByteString.Char8 as BS8
+import           Data.Maybe (mapMaybe)
+import qualified Data.Set as S
+
+import qualified AutoInstrument.Internal.GhcFacade as Ghc
+import qualified AutoInstrument.Internal.Config as Cfg
+
+parsedResultAction
+  :: [Ghc.CommandLineOption]
+  -> Ghc.ModSummary
+  -> Ghc.ParsedResult
+  -> Ghc.Hsc Ghc.ParsedResult
+parsedResultAction opts modSummary
+    parsedResult@Ghc.ParsedResult
+      {Ghc.parsedResultModule = prm@Ghc.HsParsedModule
+        {Ghc.hpm_module = Ghc.L modLoc mo@Ghc.HsModule{Ghc.hsmodDecls}}} = do
+
+  let modName = Ghc.moduleName $ Ghc.ms_mod modSummary
+      unitId = Ghc.toUnitId . Ghc.moduleUnit $ Ghc.ms_mod modSummary
+
+  hscEnv <- Ghc.getHscEnv
+  result <- liftIO $
+    Ghc.findImportedModule hscEnv (Ghc.mkModuleName "AutoInstrument.Internal.Types") Ghc.NoPkgQual
+  otelMod <-
+    case result of
+      Ghc.Found _ m -> pure m
+      _ -> error "AutoInstrument.Internal.Types module not found"
+  let occ = Ghc.mkVarOcc "autoInstrument"
+  autoInstrumentName <- liftIO $ Ghc.lookupNameCache (Ghc.hsc_NC hscEnv) otelMod occ
+
+  mConfig <- liftIO $ fmap Cfg.getConfig <$> Cfg.getConfigCache opts
+
+  case mConfig of
+    Nothing -> pure parsedResult
+      { Ghc.parsedResultMessages = (Ghc.parsedResultMessages parsedResult)
+        { Ghc.psErrors =
+            let msg = Ghc.mkParseError "Failed to load auto instrumentation config"
+             in Ghc.addMessage msg . Ghc.psErrors $ Ghc.parsedResultMessages parsedResult
+        }
+      }
+    Just config -> do
+      let matches = S.fromList $ getMatches config hsmodDecls
+
+          newDecls = instrumentDecl modName unitId autoInstrumentName matches <$> hsmodDecls
+
+      pure parsedResult
+        { Ghc.parsedResultModule = prm
+          { Ghc.hpm_module = Ghc.L modLoc mo
+            { Ghc.hsmodDecls = newDecls
+            }
+          }
+        }
+
+getMatches
+  :: Cfg.Config
+  -> [Ghc.LHsDecl Ghc.GhcPs]
+  -> [Ghc.OccName]
+getMatches cfg = concat . mapMaybe go where
+  go (Ghc.L _ (Ghc.SigD _ (Ghc.TypeSig _ lhs (Ghc.HsWC _ (Ghc.L _ (Ghc.HsSig _ _ (Ghc.L _ ty)))))))
+    | isTargetTy [] ty = Just (Ghc.rdrNameOcc . Ghc.unLoc <$> lhs)
+  go _ = Nothing
+  isTargetTy preds = \case
+    Ghc.HsForAllTy _ _ (Ghc.L _ body) -> isTargetTy preds body
+    Ghc.HsQualTy _ (Ghc.L _ ctx) (Ghc.L _ body) ->
+      isTargetTy (preds ++ fmap Ghc.unLoc ctx) body
+    app@Ghc.HsAppTy{} -> check preds app
+    var@Ghc.HsTyVar{} -> check preds var
+    Ghc.HsFunTy _ _ _ (Ghc.L _ nxt) -> isTargetTy preds nxt
+    Ghc.HsParTy _ (Ghc.L _ nxt) -> isTargetTy preds nxt
+    Ghc.HsDocTy _ (Ghc.L _ nxt) _ -> isTargetTy preds nxt
+    _ -> False
+
+  check
+    :: [Ghc.HsType Ghc.GhcPs]
+    -> Ghc.HsType Ghc.GhcPs
+    -> Bool
+  check preds expr =
+    any (matchTarget preds expr) (Cfg.targets cfg)
+    && not (any (matchTarget preds expr) (Cfg.exclusions cfg))
+
+  matchTarget preds expr = \case
+    Cfg.Constructor conTarget -> checkTy True conTarget expr
+    Cfg.Constraints predTarget -> checkPred preds predTarget
+
+  checkTy
+    :: Bool
+    -> Cfg.TargetCon
+    -> Ghc.HsType Ghc.GhcPs
+    -> Bool
+  checkTy top t (Ghc.HsParTy _ (Ghc.L _ x)) = checkTy top t x
+  checkTy top t (Ghc.HsDocTy _ (Ghc.L _ x) _) = checkTy top t x
+  checkTy _ (Cfg.TyVar name) (Ghc.HsTyVar _ _ (Ghc.L _ rdrName)) =
+    BS8.pack name == Ghc.bytesFS (Ghc.occNameFS $ Ghc.rdrNameOcc rdrName)
+  checkTy top target@(Cfg.App x y) (Ghc.HsAppTy _ (Ghc.L _ con) (Ghc.L _ arg)) =
+    (checkTy False y arg && checkTy False x con )
+    || (top && checkTy True target con)
+  checkTy True target@(Cfg.TyVar _) (Ghc.HsAppTy _ (Ghc.L _ con) _) =
+    checkTy True target con
+  checkTy _ Cfg.Unit (Ghc.HsTupleTy _ Ghc.HsBoxedOrConstraintTuple []) = True
+  checkTy _ (Cfg.Tuple targets) (Ghc.HsTupleTy _ Ghc.HsBoxedOrConstraintTuple exprs) =
+    and $ zipWith (checkTy False) targets (Ghc.unLoc <$> exprs)
+  checkTy _ Cfg.WC _ = True
+  checkTy _ _ _ = False
+
+  checkPred
+    :: [Ghc.HsType Ghc.GhcPs]
+    -> Cfg.ConstraintSet
+    -> Bool
+  checkPred preds predSet =
+    all (\p -> any (checkTy True p) preds)
+        (S.toList predSet)
+
+instrumentDecl
+  :: Ghc.ModuleName
+  -> Ghc.UnitId
+  -> Ghc.Name
+  -> S.Set Ghc.OccName
+  -> Ghc.LHsDecl Ghc.GhcPs
+  -> Ghc.LHsDecl Ghc.GhcPs
+instrumentDecl modName unitId instrName targets
+    (Ghc.L loc (Ghc.ValD vX fb@Ghc.FunBind
+      { Ghc.fun_matches = mg@Ghc.MG
+        { Ghc.mg_alts = Ghc.L altsLoc alts }, Ghc.fun_id}))
+  | Ghc.rdrNameOcc (Ghc.unLoc fun_id) `S.member` targets
+  = let newAlts = (fmap . fmap)
+          (instrumentMatch modName unitId (Ghc.unLoc fun_id) instrName)
+          alts
+     in Ghc.L loc (Ghc.ValD vX (fb
+       { Ghc.fun_matches = mg
+         { Ghc.mg_alts = Ghc.L altsLoc newAlts }}))
+instrumentDecl _ _ _ _ x = x
+
+instrumentMatch
+  :: Ghc.ModuleName
+  -> Ghc.UnitId
+  -> Ghc.RdrName
+  -> Ghc.Name
+  -> Ghc.Match Ghc.GhcPs (Ghc.GenLocated Ghc.SrcSpanAnnA (Ghc.HsExpr Ghc.GhcPs))
+  -> Ghc.Match Ghc.GhcPs (Ghc.GenLocated Ghc.SrcSpanAnnA (Ghc.HsExpr Ghc.GhcPs))
+instrumentMatch modName unitId bindName instrName match =
+  match
+    { Ghc.m_grhss = (Ghc.m_grhss match)
+      { Ghc.grhssGRHSs = (fmap . fmap) modifyGRH (Ghc.grhssGRHSs (Ghc.m_grhss match)) }
+    }
+  where
+    modifyGRH :: Ghc.GRHS Ghc.GhcPs (Ghc.LHsExpr Ghc.GhcPs)
+              -> Ghc.GRHS Ghc.GhcPs (Ghc.LHsExpr Ghc.GhcPs)
+    modifyGRH (Ghc.GRHS x guards body) =
+      Ghc.GRHS x guards (go body)
+    go :: Ghc.LHsExpr Ghc.GhcPs -> Ghc.LHsExpr Ghc.GhcPs
+    go (Ghc.L loc x) =
+      let instrVar = Ghc.HsVar Ghc.noExtField (Ghc.L Ghc.noSrcSpanA (Ghc.Exact instrName))
+          mkStringExpr = Ghc.L Ghc.noSrcSpanA . Ghc.HsLit Ghc.noAnn
+                       . Ghc.HsString Ghc.NoSourceText
+          app :: Ghc.LHsExpr Ghc.GhcPs -> Ghc.LHsExpr Ghc.GhcPs -> Ghc.LHsExpr Ghc.GhcPs
+          app l r = Ghc.L Ghc.noSrcSpanA $ Ghc.HsApp Ghc.noAnn l r
+          srcSpan = Ghc.realSrcSpan . Ghc.locA $ loc :: Ghc.RealSrcSpan
+          instr =
+            Ghc.L Ghc.noSrcSpanA instrVar
+              `app`
+            (mkStringExpr . Ghc.occNameFS $ Ghc.rdrNameOcc bindName)
+              `app`
+            mkStringExpr (Ghc.moduleNameFS modName)
+              `app`
+            mkStringExpr (Ghc.srcSpanFile srcSpan)
+              `app`
+            (mkStringExpr . Ghc.fsLit . show $ Ghc.srcSpanStartLine srcSpan)
+              `app`
+            mkStringExpr (Ghc.unitIdFS unitId)
+
+       in Ghc.L loc $ Ghc.HsApp Ghc.noAnn instr (Ghc.L loc x)
diff --git a/src/AutoInstrument/Internal/Types.hs b/src/AutoInstrument/Internal/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/AutoInstrument/Internal/Types.hs
@@ -0,0 +1,56 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedLists #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE CPP #-}
+module AutoInstrument.Internal.Types
+  ( AutoInstrument(..)
+  ) where
+
+import qualified Data.Text as T
+import           UnliftIO
+
+import qualified OpenTelemetry.Propagator as Otel
+import qualified OpenTelemetry.Trace.Core as Otel
+
+class AutoInstrument a where
+  autoInstrument
+    :: String -- function name
+    -> String -- module name
+    -> String -- file path
+    -> String -- line number
+    -> String -- package name
+    -> a -> a
+
+instance {-# INCOHERENT #-} AutoInstrument b => AutoInstrument (a -> b) where
+  autoInstrument funName modName filePath lineNum pkgName f =
+    autoInstrument funName modName filePath lineNum pkgName . f
+
+instance MonadUnliftIO m
+    => AutoInstrument (m a) where
+  autoInstrument funName modName filePath lineNum pkgName body = do
+    tp <- Otel.getGlobalTracerProvider
+    -- If the global tracer provider hasn't been initialized then there will
+    -- be no propagators. Don't create a span if this is the case because if
+    -- the function that initializes the tracer provider gets auto instrumented
+    -- then its span will not emit traces and nor will its child spans.
+    if null $ Otel.propagatorNames (Otel.getTracerProviderPropagators tp)
+    then body -- no providers - don't create a span
+    else
+      -- TODO store this in a global var as an optimization? might not want to
+      -- since the global tracer provider can potentially change.
+      let tracer = Otel.makeTracer tp "hs-opentelemetry-instrumentation-auto" Otel.tracerOptions
+
+          attrs =
+            [ (T.pack "code.function", Otel.toAttribute $ T.pack funName)
+            , (T.pack "code.namespace", Otel.toAttribute $ T.pack modName)
+            , (T.pack "code.filepath", Otel.toAttribute $ T.pack filePath)
+            , (T.pack "code.lineno", Otel.toAttribute $ T.pack lineNum)
+            , (T.pack "code.package", Otel.toAttribute $ T.pack pkgName)
+            ]
+#if MIN_VERSION_hs_opentelemetry_api(0,1,0)
+          spanArgs = Otel.addAttributesToSpanArguments attrs Otel.defaultSpanArguments
+       in Otel.inSpan tracer (T.pack funName) spanArgs body
+#else
+          spanArgs = Otel.defaultSpanArguments { Otel.attributes = attrs }
+       in Otel.inSpan'' tracer [] (T.pack funName) spanArgs (const body)
+#endif
diff --git a/test-config.toml b/test-config.toml
new file mode 100644
--- /dev/null
+++ b/test-config.toml
@@ -0,0 +1,14 @@
+targets = [
+  { type = "constructor", value = "Instrumented" },
+  { type = "constructor", value = "Partial ()" },
+  { type = "constructor", value = "WildCard (Maybe _)" },
+  { type = "constraints", value = ["InstrumentC"] },
+  { type = "constraints", value = ["C1", "C2"] }
+]
+
+exclusions = [
+  { type = "constructor", value = "NotInstrumented" },
+  { type = "constraints", value = ["NoInstrumentC"] },
+  { type = "constructor", value = "WildCardX (Maybe _)" },
+  { type = "constraints", value = ["X1", "X2"] }
+]
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,264 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedLists #-}
+{-# LANGUAGE LambdaCase #-}
+module Main (main) where
+
+import           Data.Kind (Constraint)
+import           Data.Text (Text)
+import qualified Data.HashMap.Strict as H
+import           OpenTelemetry.Attributes
+import qualified OpenTelemetry.Context as Context
+import           OpenTelemetry.Context.ThreadLocal
+import           OpenTelemetry.Exporter.InMemory
+import           OpenTelemetry.Trace
+import           OpenTelemetry.Trace.Core
+import           OpenTelemetry.Trace.Sampler
+import           Test.Tasty
+import           Test.Tasty.HUnit
+import           UnliftIO hiding (getChanContents)
+
+data SpanInfo = SpanInfo
+  { name :: Text
+  , parentName :: Maybe Text
+  , attrs :: H.HashMap Text Attribute
+  } deriving (Show, Eq)
+
+mkSpanInfo :: ImmutableSpan -> IO SpanInfo
+mkSpanInfo s = do
+  parentSpan <- traverse unsafeReadSpan $ spanParent s
+  pure SpanInfo
+    { name = spanName s
+    , parentName = spanName <$> parentSpan
+    , attrs = H.delete "thread.id" . snd . getAttributes $ spanAttributes s
+    }
+
+withGlobalTracing :: (OutChan ImmutableSpan -> IO a) -> IO a
+withGlobalTracing act = do
+  _ <- attachContext Context.empty
+  bracket
+    initializeTracing
+    (shutdownTracerProvider . fst)
+    (\(_, ref) -> act ref)
+
+initializeTracing :: IO (TracerProvider, OutChan ImmutableSpan)
+initializeTracing = do
+  (_, tracerOptions') <- getTracerProviderInitializationOptions
+
+  (inMemoryProc, spansChan) <- inMemoryChannelExporter
+  let processors' = [inMemoryProc]
+
+  provider <-
+    createTracerProvider
+      processors'
+      tracerOptions' {tracerProviderOptionsSampler = alwaysOn}
+  setGlobalTracerProvider provider
+
+  pure (provider, spansChan)
+
+type Instrumented a = IO a
+
+t1 :: Instrumented ()
+t1 = do
+  t2
+  t3
+  t2
+
+t2 :: Instrumented ()
+t2 = pure ()
+
+t3 :: IO ()
+t3 = pure ()
+
+type NotInstrumented = IO
+
+t4 :: NotInstrumented ()
+t4 = t2
+
+type InstrumentC :: Constraint
+type InstrumentC = ()
+
+t5 :: InstrumentC => IO ()
+t5 = t2
+
+type NoInstrumentC :: Constraint
+type NoInstrumentC = ()
+
+t6 :: NoInstrumentC => Instrumented ()
+t6 = t2
+
+type Partial a b = IO b
+
+t7 :: Partial Bool ()
+t7 = t2
+
+t8 :: Partial () ()
+t8 = t2
+
+type WildCard = IO
+type WildCardX = IO
+
+t9 :: WildCard (Maybe Bool)
+t9 = pure Nothing
+
+t10 :: WildCard ()
+t10 = pure ()
+
+t11 :: WildCardX (Maybe Bool)
+t11 = pure Nothing
+
+type C1 :: Constraint
+type C1 = ()
+
+type C2 :: Constraint
+type C2 = ()
+
+t12 :: C1 => IO ()
+t12 = pure ()
+
+t13 :: (C1, C2) => IO ()
+t13 = pure ()
+
+type X1 :: Constraint
+type X1 = ()
+
+type X2 :: Constraint
+type X2 = ()
+
+t14 :: (C1, X1, C2, X2) => IO ()
+t14 = pure ()
+
+t15 :: (X1, X2) => Instrumented ()
+t15 = pure ()
+
+t16 :: X2 => Instrumented ()
+t16 = pure ()
+
+t17 :: a -> Instrumented a
+t17 = pure
+
+main :: IO ()
+main =
+  withGlobalTracing $ \spansChan -> do
+    defaultMain (testTree spansChan)
+
+testTree :: OutChan ImmutableSpan -> TestTree
+testTree spansChan = testGroup "Tests"
+  [ testCase "nested spans" (nestedSpans spansChan)
+  , testCase "ignore excluded constructor" (excludedCon spansChan)
+  , testCase "simple constraint rule" (simpleConstraint spansChan)
+  , testCase "ignore excluded constraint" (excludeConstraint spansChan)
+  , testCase "partially applied constructor rule" (partialCon spansChan)
+  , testCase "rule with wildcard placeholder" (wildCard spansChan)
+  , testCase "multi constraint rule" (multiPred spansChan)
+  , testCase "multi constraint exclusion" (multiPredX spansChan)
+  , testCase "point-free" (pointFree spansChan)
+  ]
+
+nestedSpans :: OutChan ImmutableSpan -> Assertion
+nestedSpans spansChan = do
+  t1
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "67" "t2" (Just "t1")
+    , spanInfo "67" "t2" (Just "t1")
+    , spanInfo "61" "t1" Nothing
+    ]
+
+excludedCon :: OutChan ImmutableSpan -> Assertion
+excludedCon spansChan = do
+  t4
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "67" "t2" Nothing
+    ]
+
+simpleConstraint :: OutChan ImmutableSpan -> Assertion
+simpleConstraint spansChan = do
+  t5
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "67" "t2" (Just "t5")
+    , spanInfo "81" "t5" Nothing
+    ]
+
+excludeConstraint :: OutChan ImmutableSpan -> Assertion
+excludeConstraint spansChan = do
+  t6
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "67" "t2" Nothing ]
+
+partialCon :: OutChan ImmutableSpan -> Assertion
+partialCon spansChan = do
+  t7
+  t8
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "67" "t2" Nothing
+    , spanInfo "67" "t2" (Just "t8")
+    , spanInfo "95" "t8" Nothing
+    ]
+
+wildCard :: OutChan ImmutableSpan -> Assertion
+wildCard spansChan = do
+  _ <- t9
+  t10
+  _ <- t11
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "101" "t9" Nothing ]
+
+multiPred :: OutChan ImmutableSpan -> Assertion
+multiPred spansChan = do
+  t12
+  t13
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "119" "t13" Nothing ]
+
+multiPredX :: OutChan ImmutableSpan -> Assertion
+multiPredX spansChan = do
+  t14
+  t15
+  t16
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "134" "t16" Nothing ]
+
+pointFree :: OutChan ImmutableSpan -> Assertion
+pointFree spansChan = do
+  t17 ()
+  spans <- getSpans spansChan
+  spans @?=
+    [ spanInfo "137" "t17" Nothing ]
+
+spanInfo :: Text -> Text -> Maybe Text -> SpanInfo
+spanInfo lineNo funName mParentName =
+  SpanInfo
+    { name = funName
+    , parentName = mParentName
+    , attrs =
+      [ ("code.lineno", AttributeValue (TextAttribute lineNo))
+      , ("code.filepath", AttributeValue (TextAttribute "test/Main.hs"))
+      , ("code.function", AttributeValue (TextAttribute funName))
+      , ("code.namespace", AttributeValue (TextAttribute "Main"))
+      , ("code.package", AttributeValue (TextAttribute "main"))
+      ]
+    }
+
+getSpans :: OutChan ImmutableSpan -> IO [SpanInfo]
+getSpans chan = do
+  (element, _) <- tryReadChan chan
+  tryRead element >>= \case
+    Nothing -> do
+      addPlaceholderSpan
+      pure []
+    Just e -> do
+      si <- mkSpanInfo e
+      (si :) <$> getSpans chan
+
+addPlaceholderSpan :: IO ()
+addPlaceholderSpan = do
+  tp <- getGlobalTracerProvider
+  let tracer = makeTracer tp "test" tracerOptions
+  inSpan tracer "_placeholder_" defaultSpanArguments $ pure ()
