diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,3 @@
+# Changelog for hs-opentelemetry-exporter-otlp
+
+## Unreleased changes
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Ian Duncan (c) 2021
+
+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 Ian Duncan 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,1 @@
+# hs-opentelemetry-exporter-otlp
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/hs-opentelemetry-exporter-otlp.cabal b/hs-opentelemetry-exporter-otlp.cabal
new file mode 100644
--- /dev/null
+++ b/hs-opentelemetry-exporter-otlp.cabal
@@ -0,0 +1,81 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+
+name:           hs-opentelemetry-exporter-otlp
+version:        0.0.1.0
+synopsis:       OpenTelemetry exporter supporting the standard OTLP protocol
+description:    Please see the README on GitHub at <https://github.com/iand675/hs-opentelemetry/tree/main/exporters/otlp#readme>
+category:       OpenTelemetry, Telemetry, Monitoring, Observability, Metrics
+homepage:       https://github.com/iand675/hs-opentelemetry#readme
+bug-reports:    https://github.com/iand675/hs-opentelemetry/issues
+author:         Ian Duncan
+maintainer:     ian@iankduncan.com
+copyright:      2021 Ian Duncan
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    ChangeLog.md
+
+source-repository head
+  type: git
+  location: https://github.com/iand675/hs-opentelemetry
+
+library
+  exposed-modules:
+      OpenTelemetry.Exporter.OTLP
+  other-modules:
+      Paths_hs_opentelemetry_exporter_otlp
+  hs-source-dirs:
+      src
+  ghc-options: -Wall
+  build-depends:
+      base >=4.7 && <5
+    , bytestring
+    , case-insensitive
+    , clock
+    , hs-opentelemetry-api
+    , hs-opentelemetry-otlp
+    , http-client
+    , http-conduit
+    , http-types
+    , microlens
+    , mtl
+    , proto-lens >=0.7.1.0
+    , text
+    , unordered-containers
+    , vector
+    , vector-builder
+  default-language: Haskell2010
+
+test-suite hs-opentelemetry-exporter-otlp-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_hs_opentelemetry_exporter_otlp
+  hs-source-dirs:
+      test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , bytestring
+    , case-insensitive
+    , clock
+    , hs-opentelemetry-api
+    , hs-opentelemetry-exporter-otlp
+    , hs-opentelemetry-otlp
+    , http-client
+    , http-conduit
+    , http-types
+    , microlens
+    , mtl
+    , proto-lens >=0.7.1.0
+    , text
+    , unordered-containers
+    , vector
+    , vector-builder
+  default-language: Haskell2010
diff --git a/src/OpenTelemetry/Exporter/OTLP.hs b/src/OpenTelemetry/Exporter/OTLP.hs
new file mode 100644
--- /dev/null
+++ b/src/OpenTelemetry/Exporter/OTLP.hs
@@ -0,0 +1,352 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NumericUnderscores #-}
+{-# LANGUAGE RecordWildCards #-}
+-----------------------------------------------------------------------------
+-- |
+-- Module      :  OpenTelemetry.Exporter.OTLP
+-- Copyright   :  (c) Ian Duncan, 2021
+-- License     :  BSD-3
+-- Description :  OTLP Exporter
+-- Maintainer  :  Ian Duncan
+-- Stability   :  experimental
+-- Portability :  non-portable (GHC extensions)
+--
+-- The OTLP Exporter is the recommend exporter format to use where possible.
+--
+-- A number of vendors offer support for exporting traces, logs, and metrics using the vendor-agnostic OTLP protocol.
+--
+-- Additionally, the OTLP format is supported by the <https://opentelemetry.io/docs/collector/ OpenTelemetry Collector>.
+--
+-- The OpenTelemetry Collector offers a vendor-agnostic implementation of how to receive, process and export telemetry data. 
+-- It removes the need to run, operate, and maintain multiple agents/collectors. 
+-- This works with improved scalability and supports open-source observability data formats (e.g. Jaeger, Prometheus, Fluent Bit, etc.) sending to 
+-- one or more open-source or commercial back-ends. The local Collector agent is the default location to which instrumentation libraries export 
+-- their telemetry data.
+--
+-----------------------------------------------------------------------------
+module OpenTelemetry.Exporter.OTLP (
+    -- * Initializing the exporter
+    otlpExporter
+    -- * Configuring the exporter
+  , OTLPExporterConfig(..)
+  , CompressionFormat(..)
+  , Protocol(..)
+  , loadExporterEnvironmentVariables
+    -- * Default local endpoints
+  , otlpExporterHttpEndpoint
+  , otlpExporterGRpcEndpoint
+) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (SomeException(..), try)
+import Control.Monad.IO.Class
+import qualified Data.ByteString.Char8 as C
+import Data.Bits (shiftL)
+import qualified Data.CaseInsensitive as CI
+import Data.Text (Text)
+import qualified Data.Text.Encoding as T
+import Data.ProtoLens.Encoding
+import Data.ProtoLens.Message
+import System.Environment
+import qualified OpenTelemetry.Baggage as Baggage
+import qualified OpenTelemetry.Trace.Core as OT
+import Proto.Opentelemetry.Proto.Trace.V1.Trace (Span'SpanKind (Span'SPAN_KIND_SERVER, Span'SPAN_KIND_CLIENT, Span'SPAN_KIND_PRODUCER, Span'SPAN_KIND_CONSUMER, Span'SPAN_KIND_INTERNAL), Status'StatusCode (Status'STATUS_CODE_OK, Status'STATUS_CODE_ERROR, Status'STATUS_CODE_UNSET), InstrumentationLibrarySpans, Span, Span'Link, Span'Event)
+import Proto.Opentelemetry.Proto.Trace.V1.Trace_Fields
+import Network.HTTP.Client
+import Network.HTTP.Simple (httpBS)
+import Network.HTTP.Types.Header
+import Network.HTTP.Types.Status
+import OpenTelemetry.Exporter
+import Data.Vector (Vector)
+import Data.Maybe
+import Lens.Micro
+import Proto.Opentelemetry.Proto.Collector.Trace.V1.TraceService (ExportTraceServiceRequest)
+import qualified Data.Vector as Vector
+import OpenTelemetry.Trace.Id (traceIdBytes, spanIdBytes)
+import OpenTelemetry.Attributes
+import OpenTelemetry.Resource
+import Proto.Opentelemetry.Proto.Common.V1.Common
+import Proto.Opentelemetry.Proto.Common.V1.Common_Fields
+import Text.Read (readMaybe)
+import Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as H
+import qualified Data.Vector as V
+import OpenTelemetry.Trace.Core (timestampNanoseconds)
+import OpenTelemetry.Util
+
+data CompressionFormat = None | GZip
+data Protocol = {- GRpc | HttpJson | -} HttpProtobuf
+  -- ^ Note: grpc and http/json will likely be supported eventually,
+  -- but not yet.
+
+otlpExporterHttpEndpoint :: C.ByteString
+otlpExporterHttpEndpoint = "http://localhost:4318"
+
+otlpExporterGRpcEndpoint :: C.ByteString
+otlpExporterGRpcEndpoint = "http://localhost:4317"
+
+
+data OTLPExporterConfig = OTLPExporterConfig
+  { otlpEndpoint :: Maybe String
+  , otlpTracesEndpoint :: Maybe String
+  , otlpMetricsEndpoint :: Maybe String
+  , otlpInsecure :: Maybe Bool
+  , otlpSpanInsecure :: Maybe Bool
+  , otlpMetricInsecure :: Maybe Bool
+  , otlpCertificate :: Maybe FilePath
+  , otlpTracesCertificate :: Maybe FilePath
+  , otlpMetricCertificate :: Maybe FilePath
+  , otlpHeaders :: Maybe [Header]
+  , otlpTracesHeaders :: Maybe [Header]
+  , otlpMetricsHeaders :: Maybe [Header]
+  , otlpCompression :: Maybe CompressionFormat
+  , otlpTracesCompression :: Maybe CompressionFormat
+  , otlpMetricsCompression :: Maybe CompressionFormat
+  , otlpTimeout :: Maybe Int
+  -- ^ Measured in seconds
+  , otlpTracesTimeout :: Maybe Int
+  , otlpMetricsTimeout :: Maybe Int
+  , otlpProtocol :: Maybe Protocol
+  , otlpTracesProtocol :: Maybe Protocol
+  , otlpMetricsProtocol :: Maybe Protocol
+  }
+
+loadExporterEnvironmentVariables :: MonadIO m => m OTLPExporterConfig
+loadExporterEnvironmentVariables = liftIO $ do
+  OTLPExporterConfig <$>
+    lookupEnv "OTEL_EXPORTER_OTLP_ENDPOINT" <*>
+    lookupEnv "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" <*>
+    lookupEnv "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" <*>
+    (fmap (== "true") <$> lookupEnv "OTEL_EXPORTER_OTLP_INSECURE") <*>
+    (fmap (== "true") <$> lookupEnv "OTEL_EXPORTER_OTLP_SPAN_INSECURE") <*>
+    (fmap (== "true") <$> lookupEnv "OTEL_EXPORTER_OTLP_METRIC_INSECURE") <*>
+    lookupEnv "OTEL_EXPORTER_OTLP_CERTIFICATE" <*>
+    lookupEnv "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE" <*>
+    lookupEnv "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE" <*>
+    (fmap decodeHeaders <$> lookupEnv "OTEL_EXPORTER_OTLP_HEADERS") <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_TRACES_HEADERS" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_METRICS_HEADERS" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_COMPRESSION" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_METRICS_COMPRESSION" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_TIMEOUT" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_PROTOCOL" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" <*>
+    pure Nothing <*>
+    -- TODO lookupEnv "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
+    pure Nothing
+  where
+    decodeHeaders hsString = case Baggage.decodeBaggageHeader $ C.pack hsString of
+      Left _ -> mempty
+      Right baggageFmt ->
+        (\(k, v) -> (CI.mk $ Baggage.tokenValue k, T.encodeUtf8 $ Baggage.value v)) <$> H.toList (Baggage.values baggageFmt)
+
+protobufMimeType :: C.ByteString
+protobufMimeType = "application/x-protobuf"
+
+-- | Initial the OTLP 'Exporter'
+otlpExporter :: (MonadIO m) => OTLPExporterConfig -> m Exporter
+otlpExporter conf = do
+  -- TODO, url parsing is janky
+-- TODO configurable retryDelay, maximum retry counts
+  req <- liftIO $ parseRequest (maybe "http://localhost:4318/v1/traces" (<> "/v1/traces") (otlpEndpoint conf))
+  let baseReq = req
+        { method = "POST"
+        , requestHeaders =
+            (hContentType, protobufMimeType) :
+            (hAcceptEncoding, protobufMimeType) :
+            fromMaybe [] (otlpHeaders conf) ++
+            fromMaybe [] (otlpTracesHeaders conf) ++
+            requestHeaders req
+        }
+  pure $ Exporter
+    { exporterExport = \spans_ -> do
+        let anySpansToExport = H.size spans_ /= 0 && not (all V.null $ H.elems spans_)
+        if anySpansToExport
+          then do
+            result <- try $ exporterExportCall baseReq spans_
+            case result of
+              Left err -> do
+                print err
+                pure $ Failure $ Just err
+              Right ok -> pure ok
+          else pure Success
+    , exporterShutdown = pure ()
+    }
+  where
+    retryDelay = 100_000 -- 100ms
+    maxRetryCount = 5
+    isRetryableStatusCode status_ = status_ == status429 || status_ == status503
+    isRetryableException = \case
+      ResponseTimeout -> True
+      ConnectionTimeout -> True
+      ConnectionFailure _ -> True
+      ConnectionClosed -> True
+      _ -> False
+
+    exporterExportCall baseReq spans_ = do
+      msg <- encodeMessage <$> immutableSpansToProtobuf spans_
+      -- TODO handle server disconnect
+      let req = baseReq
+            { requestBody =
+                RequestBodyBS msg
+            }
+      sendReq req 0 -- TODO =<< getTime for maximum cutoff
+
+    sendReq req backoffCount = do
+      eResp <- try $ httpBS req
+
+      let exponentialBackoff = if backoffCount == maxRetryCount
+            then pure $ Failure Nothing
+            else do
+              threadDelay (retryDelay `shiftL` backoffCount)
+              sendReq req (backoffCount + 1)
+
+      case eResp of
+        Left err@(HttpExceptionRequest _ e) -> if isRetryableException e
+          then exponentialBackoff
+          else pure $ Failure $ Just $ SomeException err
+        Left err -> pure $ Failure $ Just $ SomeException err
+        Right resp -> if isRetryableStatusCode (responseStatus resp)
+          then case lookup hRetryAfter $ responseHeaders resp of
+            Nothing -> exponentialBackoff
+            Just retryAfter -> do
+              -- TODO support date in retry-after header
+              case readMaybe $ C.unpack retryAfter of
+                Nothing -> exponentialBackoff
+                Just seconds -> do
+                  threadDelay (seconds * 1_000_000)
+                  sendReq req (backoffCount + 1)
+
+          else pure $! if statusCode (responseStatus resp) >= 300
+            then Failure Nothing
+            else Success
+
+attributesToProto :: Attributes -> Vector KeyValue
+attributesToProto =
+  V.fromList .
+  fmap attributeToKeyValue .
+  H.toList .
+  snd .
+  getAttributes
+  where
+    primAttributeToAnyValue = \case
+      TextAttribute t -> defMessage & stringValue .~ t
+      BoolAttribute b -> defMessage & boolValue .~ b
+      DoubleAttribute d -> defMessage & doubleValue .~ d
+      IntAttribute i -> defMessage & intValue .~ i
+    attributeToKeyValue :: (Text, Attribute) -> KeyValue
+    attributeToKeyValue (k, v) = defMessage
+      & key .~ k
+      & value .~ (case v of
+        AttributeValue a -> primAttributeToAnyValue a
+        AttributeArray a -> defMessage
+          & arrayValue .~ (defMessage & values .~ fmap primAttributeToAnyValue a)
+      )
+
+
+immutableSpansToProtobuf :: MonadIO m => HashMap OT.InstrumentationLibrary (Vector OT.ImmutableSpan) -> m ExportTraceServiceRequest
+immutableSpansToProtobuf completedSpans = do
+  spansByLibrary <- mapM makeInstrumentationLibrarySpans spanGroupList
+  pure $ defMessage
+    & vec'resourceSpans .~
+      Vector.singleton
+        ( defMessage
+            & resource .~
+              ( defMessage
+                  & vec'attributes .~ attributesToProto (getMaterializedResourcesAttributes someResourceGroup)
+                  -- TODO
+                  & droppedAttributesCount .~ 0
+              )
+            -- TODO, seems like spans need to be emitted via an API
+            -- that lets us keep them grouped by instrumentation originator
+            & instrumentationLibrarySpans .~ spansByLibrary
+                
+        )
+  where
+    -- TODO this won't work right if multiple TracerProviders are exporting to a single OTLP exporter with different resources
+    someResourceGroup = case spanGroupList of
+      [] -> emptyMaterializedResources
+      ((_, r):_) -> case r V.!? 0 of
+        Nothing -> emptyMaterializedResources
+        Just s -> OT.getTracerProviderResources $ OT.getTracerTracerProvider $ OT.spanTracer s
+
+    spanGroupList = H.toList completedSpans
+
+    makeInstrumentationLibrarySpans :: MonadIO m => (OT.InstrumentationLibrary, Vector OT.ImmutableSpan) -> m InstrumentationLibrarySpans
+    makeInstrumentationLibrarySpans (library, completedSpans_) = do
+      spans_ <- mapM makeSpan completedSpans_
+      pure $ defMessage
+        & instrumentationLibrary .~ (
+            defMessage
+              & Proto.Opentelemetry.Proto.Trace.V1.Trace_Fields.name .~ OT.libraryName library
+              & version .~ OT.libraryVersion library
+          )
+        & vec'spans .~ spans_
+      -- & schemaUrl .~ "" -- TODO
+
+makeSpan :: MonadIO m => OT.ImmutableSpan -> m Span
+makeSpan completedSpan = do
+  let startTime = timestampNanoseconds (OT.spanStart completedSpan) 
+  parentSpanF <- do
+    case OT.spanParent completedSpan of
+      Nothing -> pure id
+      Just s -> do
+        spanCtxt <- OT.spanId <$> OT.getSpanContext s
+        pure (\otlpSpan -> otlpSpan & parentSpanId .~ spanIdBytes spanCtxt)
+
+  pure $ defMessage
+    & traceId .~ traceIdBytes (OT.traceId $ OT.spanContext completedSpan)
+    & spanId .~ spanIdBytes (OT.spanId $ OT.spanContext completedSpan)
+    & traceState .~ "" -- TODO (_ $ OT.traceState $ OT.spanContext completedSpan)
+    & Proto.Opentelemetry.Proto.Trace.V1.Trace_Fields.name .~ OT.spanName completedSpan
+    & kind .~ (case OT.spanKind completedSpan of
+      OT.Server -> Span'SPAN_KIND_SERVER
+      OT.Client -> Span'SPAN_KIND_CLIENT
+      OT.Producer -> Span'SPAN_KIND_PRODUCER
+      OT.Consumer -> Span'SPAN_KIND_CONSUMER
+      OT.Internal -> Span'SPAN_KIND_INTERNAL)
+    & startTimeUnixNano .~ startTime
+    & endTimeUnixNano .~ maybe startTime timestampNanoseconds (OT.spanEnd completedSpan)
+    & vec'attributes .~ attributesToProto (OT.spanAttributes completedSpan)
+    & droppedAttributesCount .~ fromIntegral (fst (getAttributes $ OT.spanAttributes completedSpan))
+    & vec'events .~ fmap makeEvent (appendOnlyBoundedCollectionValues $ OT.spanEvents completedSpan)
+    & droppedEventsCount .~ fromIntegral (appendOnlyBoundedCollectionDroppedElementCount (OT.spanEvents completedSpan))
+    & vec'links .~ fmap makeLink (frozenBoundedCollectionValues $ OT.spanLinks completedSpan) -- TODO
+    & droppedLinksCount .~ fromIntegral (frozenBoundedCollectionDroppedElementCount (OT.spanLinks completedSpan))
+    & status .~ (case OT.spanStatus completedSpan of
+      OT.Unset -> defMessage
+        & code .~ Status'STATUS_CODE_UNSET
+      OT.Ok -> defMessage
+        & code .~ Status'STATUS_CODE_OK
+      (OT.Error e) -> defMessage
+        & code .~ Status'STATUS_CODE_ERROR
+        & message .~ e
+    )
+    & parentSpanF
+
+makeEvent :: OT.Event -> Span'Event
+makeEvent e = defMessage
+  & timeUnixNano .~ timestampNanoseconds (OT.eventTimestamp e)
+  & Proto.Opentelemetry.Proto.Trace.V1.Trace_Fields.name .~ OT.eventName e
+  & vec'attributes .~ attributesToProto (OT.eventAttributes e)
+  & droppedAttributesCount .~ 0
+
+makeLink :: OT.Link -> Span'Link
+makeLink l = defMessage
+  & traceId .~ traceIdBytes (OT.traceId $ OT.linkContext l)
+  & spanId .~ spanIdBytes (OT.spanId $ OT.linkContext l)
+  & vec'attributes .~ attributesToProto (OT.linkAttributes l)
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+main :: IO ()
+main = putStrLn "Test suite not yet implemented"
