packages feed

opentelemetry-extra-0.7.0: src/OpenTelemetry/ZipkinExporter.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module OpenTelemetry.ZipkinExporter where

-- Zipkin V2 protocol spec: https://github.com/openzipkin/zipkin-api/blob/master/zipkin2-api.yaml

import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.IO.Class
import Data.Coerce
import qualified Data.HashMap.Strict as HM
import Data.Scientific
import qualified Data.Text as T
import qualified Jsonifier as J
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import Network.HTTP.Types
import OpenTelemetry.Common
import OpenTelemetry.Debug
import OpenTelemetry.SpanContext
import System.IO.Unsafe
import Text.Printf

data ZipkinSpan = ZipkinSpan
  { zsConfig :: ZipkinConfig,
    zsSpan :: Span
  }

tagValue2text :: TagValue -> J.Json
tagValue2text tv = J.textString $ case tv of
  (StringTagValue (TagVal s)) -> s
  (BoolTagValue b) -> if b then "true" else "false"
  (IntTagValue i) -> T.pack $ show i
  (DoubleTagValue d) -> T.pack $ show (fromFloatDigits d)

jSpan :: ZipkinConfig -> Span -> J.Json
jSpan ZipkinConfig {..} s@(Span {..}) =
  let TId tid = spanTraceId s
      SId sid = spanId s
      ts = spanStartedAt `div` 1000
      duration = (spanFinishedAt - spanStartedAt) `div` 1000
   in J.object $
        [ ("name", J.textString spanOperation),
          ("traceId", J.textString $ T.pack (printf "%016x" tid)),
          ("id", J.textString $ T.pack (printf "%016x" sid)),
          ("timestamp", J.wordNumber $ fromIntegral ts),
          ("duration", J.wordNumber $ fromIntegral duration),
          ("localEndpoint", J.object [("serviceName", J.textString zServiceName)]),
          ( "tags",
            J.object
              ( (fmap J.textString <$> zGlobalTags)
                  <> [(k, tagValue2text v) | ((TagName k), v) <- HM.toList spanTags]
              )
          ),
          ( "annotations",
            J.array
              [ J.object
                  [ ("timestamp", J.wordNumber $ fromIntegral (t `div` 1000)),
                    ("value", J.textString $ coerce v)
                  ]
                | SpanEvent t _ v <- spanEvents
              ]
          )
        ]
          <> (maybe [] (\(SId psid) -> [("parentId", J.wordNumber $ fromIntegral psid)]) spanParentId)

data ZipkinConfig = ZipkinConfig
  { zEndpoint :: String,
    zServiceName :: T.Text,
    zGlobalTags :: [(T.Text, T.Text)],
    zGracefulShutdownTimeoutSeconds :: Word,
    zSpanQueueSize :: Word
  }

localhostZipkinConfig :: T.Text -> ZipkinConfig
localhostZipkinConfig service =
  ZipkinConfig
    { zEndpoint = "http://localhost:9411/api/v2/spans",
      zServiceName = service,
      zGlobalTags = mempty,
      zGracefulShutdownTimeoutSeconds = 5,
      zSpanQueueSize = 2048
    }

data ZipkinClient = ZipkinClient
  { zcConfig :: ZipkinConfig,
    zcSenderThread :: Async (),
    zcSenderQueue :: TBQueue Span,
    zcShutdownVar :: TVar Bool
  }

createZipkinSpanExporter :: MonadIO m => ZipkinConfig -> m (Exporter Span)
createZipkinSpanExporter cfg = liftIO do
  client <- mkClient cfg
  pure
    $! Exporter
      ( \sps -> do
          let q = zcSenderQueue client
          atomically $ do
            q_population <- fromIntegral <$> lengthTBQueue q
            let q_vacancy = fromIntegral (zSpanQueueSize (zcConfig client) - q_population)
            modifyTVar droppedSpanCountVar (\x -> x + length sps - q_vacancy)
            mapM_
              (writeTBQueue q)
              (take q_vacancy sps)
          pure ExportSuccess
      )
      ( do
          atomically $
            writeTVar (zcShutdownVar client) True
          wait (zcSenderThread client)
      )

mkClient :: ZipkinConfig -> IO ZipkinClient
mkClient cfg@(ZipkinConfig {..}) = do
  manager <- newManager tlsManagerSettings
  q <- newTBQueueIO (fromIntegral zSpanQueueSize)
  shutdown_var <- newTVarIO False
  sender <- async $ do
    let loop = do
          (must_shutdown, sps) <- atomically $ do
            must_shutdown <- readTVar shutdown_var
            sps <- flushTBQueue q
            case (must_shutdown, sps) of
              (False, []) -> retry
              _ -> pure (must_shutdown, sps)
          case sps of
            [] -> pure ()
            _ -> reportSpans zEndpoint manager cfg sps
          dd_ "must_shutdown" must_shutdown
          case must_shutdown of
            True -> pure ()
            False -> loop
    loop
  pure $! ZipkinClient cfg sender q shutdown_var

reportSpans :: String -> Manager -> ZipkinConfig -> [Span] -> IO ()
reportSpans endpoint httpManager cfg sps = do
  dd_ "reportSpans" sps
  let body = J.toByteString $ J.array (map (jSpan cfg) sps)
      request =
        (parseRequest_ endpoint)
          { method = "POST",
            requestBody = RequestBodyBS body,
            requestHeaders = [("Content-Type", "application/json")]
          }
  resp <- httpLbs request httpManager
  case statusCode (responseStatus resp) of
    s | s `elem` [200, 202] -> do
      inc 1 reportedSpanCountVar
      pure ()
    _ -> do
      -- TODO(divanov): handle failures
      inc 1 rejectedSpanCountVar
      dd_ "body" body
      dd_ "resp status" $ responseStatus resp
      dd_ "resp" $ responseBody resp

droppedSpanCountVar :: TVar Int
droppedSpanCountVar = unsafePerformIO $ newTVarIO 0
{-# NOINLINE droppedSpanCountVar #-}

reportedSpanCountVar :: TVar Int
reportedSpanCountVar = unsafePerformIO $ newTVarIO 0
{-# NOINLINE reportedSpanCountVar #-}

rejectedSpanCountVar :: TVar Int
rejectedSpanCountVar = unsafePerformIO $ newTVarIO 0
{-# NOINLINE rejectedSpanCountVar #-}