opentelemetry-plugin-1.1.2: src/OpenTelemetry/Plugin/Shared.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-| This module provides the GHC-API-agnostic logic for this plugin (mostly
open telemetry utilities)
Because of how GHC plugins work, this module has to do some evil stuff
under the hood to work within the confines of the plugin API. That means
that you should take care to use the utilities in this module correctly
in order to avoid the plugin hanging.
This module is intended to be shared by multiple GHC versions. Please
don't depend on any GHC internal modules.
-}
module OpenTelemetry.Plugin.Shared
( -- * Plugin passes
makeWrapperPluginPasses
, getPluginShouldRecordPasses
-- * Top-level context
, initializeTopLevelContext
, getTopLevelContext
, modifyContextWithParentSpan
, PackageName (..)
-- * Root module names
, setRootModuleNames
, isRootModule
-- * Flushing
, flush
, flushMetricsWhenRootModule
, getSampler
, tracer
-- * Recording spans in 'runPhaseHook'
, SpanMap
, newSpanMap
, recordModuleStart
, recordModuleEnd
) where
import Control.Applicative ((<|>))
import Control.Concurrent.MVar (MVar)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import Data.Set (Set)
import Data.Text (Text)
import OpenTelemetry.Context (Context)
import OpenTelemetry.Trace.Sampler (Sampler(..), SamplingResult(..))
import Prelude hiding (span)
import System.Random.MWC (GenIO)
import qualified StmContainers.Map as StmMap
import OpenTelemetry.Trace
( Attribute(..)
, PrimitiveAttribute(..)
, InstrumentationLibrary(..)
, Span
, SpanArguments(..)
, SpanContext(..)
, Tracer
, TracerProvider
, TracerProviderOptions(..)
, detectInstrumentationLibrary
)
import qualified Control.Monad as Monad
import qualified Control.Concurrent.MVar as MVar
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Maybe as Maybe
import qualified Data.Set as Set
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text.Encoding
import qualified Data.Version as Version
import qualified OpenTelemetry.Context as Context
import qualified OpenTelemetry.Propagator.W3CBaggage as W3CBaggage
import qualified OpenTelemetry.Propagator.W3CTraceContext as W3CTraceContext
import qualified OpenTelemetry.Trace as Trace
import qualified OpenTelemetry.Trace.Core as Trace.Core
import qualified OpenTelemetry.Trace.Sampler as Sampler
import qualified OpenTelemetry.Trace.TraceState as TraceState
import qualified Paths_opentelemetry_plugin as Paths
import qualified System.Environment as Environment
import qualified System.IO.Unsafe as Unsafe
import qualified System.Random.MWC as MWC
import qualified Text.Read as Read
import qualified Control.Concurrent.STM as STM
{-| Very large Haskell builds can generate an enormous number of spans,
but none of the stock samplers provide a way to sample a subset of
the `Span`s within a trace.
This adds a new "spanratio" sampler which can be used to sample subset of
module spans.
-}
getSampler :: IO (Maybe Sampler)
getSampler = do
maybeSampler <- Environment.lookupEnv "OTEL_TRACES_SAMPLER"
maybeRatio <- Environment.lookupEnv "OTEL_TRACES_SAMPLER_ARG"
pure do
"spanratio" <- maybeSampler
ratioString <- maybeRatio
ratio <- Read.readMaybe ratioString
pure (spanRatioBased ratio)
{-| Like a lot of other uses of `Unsafe.unsafePerformIO` in this module, we're
doing this because the plugin interface doesn't provide a way for us to
acquire resources before returning the plugin.
-}
generator :: GenIO
generator = Unsafe.unsafePerformIO MWC.createSystemRandom
{-# NOINLINE generator #-}
spanRatioBased :: Double -> Sampler
spanRatioBased fraction = Sampler
{ getDescription =
"SpanRatioBased{" <> Text.pack (show fraction) <> "}"
, shouldSample = \context traceId_ name spanArguments -> do
case HashMap.lookup "sample" (attributes spanArguments) of
Just (AttributeValue (BoolAttribute True)) -> do
random <- MWC.uniformR (0, 1) generator
let samplingResult =
if random < fraction then RecordAndSample else Drop
traceState_ <- case Context.lookupSpan context of
Nothing ->
pure TraceState.empty
Just span ->
fmap traceState (Trace.Core.getSpanContext span)
pure (samplingResult, HashMap.empty, traceState_)
_ ->
shouldSample Sampler.alwaysOn context traceId_ name spanArguments
}
{-| Note: We don't properly shut this down using `Trace.shutdownTracerProvider`,
but all that the shutdown does is flush metrics, so instead we flush metrics
(using `flush`) at the end of compilation to make up for the lack of a
proper shutdown.
-}
tracerProvider :: TracerProvider
tracerProvider = Unsafe.unsafePerformIO do
(processors, options) <-
-- This function will collect *all* of the command line arguments
-- that were provided to GHC. This results in a huge amount of data
-- being sent. For that reason, we blank out the process arguments
-- for this section of code.
Environment.withArgs [] do
Trace.getTracerProviderInitializationOptions
maybeSampler <- getSampler
let newOptions =
case maybeSampler of
Nothing -> options
Just sampler -> options{ tracerProviderOptionsSampler = sampler }
tracerProvider_ <- Trace.createTracerProvider processors newOptions
Trace.setGlobalTracerProvider tracerProvider_
pure tracerProvider_
{-# NOINLINE tracerProvider #-}
tracer :: Tracer
tracer =
Trace.makeTracer tracerProvider $(detectInstrumentationLibrary) Trace.tracerOptions
{-| This used by the GHC plugin to create two plugin passes that start and stop
a `Span`, respectively.
In order for `Span` ancestry to be tracked correctly this takes an
@`IO` `Context`@ as an input (to read the parent `Span`'s `Context`) and
returns an @`IO` `Context`@ as an output (to read the current `Span`'s
`Context`).
-}
makeWrapperPluginPasses
:: Bool
-- ^ Whether to sample a subset of spans
-> IO Context
-- ^ Action to read the parent span's `Context`
-> Text
-- ^ Label for the current span
-> IO (IO Context, IO (), IO ())
makeWrapperPluginPasses sample getParentContext label = liftIO do
spanMVar <- liftIO MVar.newEmptyMVar
currentContextMVar <- liftIO MVar.newEmptyMVar
let beginPass = do
parentContext <- getParentContext
let spanArguments =
if sample
then
Trace.defaultSpanArguments
{ attributes =
HashMap.singleton "sample" (AttributeValue (BoolAttribute True))
}
else
Trace.defaultSpanArguments
passSpan <- Trace.createSpan tracer parentContext label spanArguments
_ <- MVar.tryPutMVar spanMVar passSpan
let currentContext = Context.insertSpan passSpan parentContext
_ <- MVar.tryPutMVar currentContextMVar currentContext
pure ()
let endPass = do
passSpan <- MVar.readMVar spanMVar
Trace.endSpan passSpan Nothing
pure (MVar.readMVar currentContextMVar, beginPass, endPass)
{-| We're intentionally **NOT** using `OpenTelemetry.Context.ThreadLocal`
here since the `GHC.Plugins.Plugin` logic doesn't necessarily run in a
single thread (@ghc@ builds can be multi-threaded). Instead, we provide
our own `Context` global variable.
-}
topLevelContextMVar :: MVar Context
topLevelContextMVar = Unsafe.unsafePerformIO MVar.newEmptyMVar
{-# NOINLINE topLevelContextMVar #-}
{- | We keep track of the module spans in this top-level 'MVar' so that it
may be shared between the driverPlugin and other plugins.
-}
topLevelSpanMapMVar :: MVar SpanMap
topLevelSpanMapMVar = Unsafe.unsafePerformIO MVar.newEmptyMVar
{-# NOINLINE topLevelSpanMapMVar #-}
getTopLevelSpan
:: PackageName
-> IO Span
getTopLevelSpan packageName = do
traceParent <- lookupEnv "TRACEPARENT"
traceState_ <- lookupEnv "TRACESTATE"
case W3CTraceContext.decodeSpanContext traceParent traceState_ of
Just spanContext ->
pure (Trace.Core.wrapSpanContext spanContext)
Nothing -> do
-- If we're not inheriting a span from
-- `TRACEPARENT`/`TRACESTATE`, then create a zero-duration span
-- whose sole purpose is to be a parent span for each module's
-- spans.
--
-- Ideally we'd like this span's duration to last for the
-- entirety of compilation, but there isn't a good way to end
-- the span when compilation is done. Also, we still need
-- *some* parent span for each module's spans, otherwise an
-- entirely new trace will be created for each new span.
-- Creating a zero-duration span is the least-worst solution.
--
-- Note that there aren't any issues with the child spans
-- lasting longer than the parent span. This is supported by
-- open telemetry and the Haskell API.
timestamp <- Trace.Core.getTimestamp
let arguments =
Trace.defaultSpanArguments
{ startTime = Just timestamp
, attributes = HashMap.fromList
[ ("packageName", Trace.Core.toAttribute packageName)
]
}
span <- Trace.createSpan tracer Context.empty "opentelemetry GHC plugin" arguments
Trace.endSpan span (Just timestamp)
pure span
getTopLevelBaggage :: IO Context
getTopLevelBaggage = do
maybeBytes <- lookupEnv "BAGGAGE"
case maybeBytes >>= W3CBaggage.decodeBaggage of
Nothing -> pure Context.empty
Just baggage -> pure (Context.insertBaggage baggage Context.empty)
lookupEnv :: String -> IO (Maybe ByteString)
lookupEnv = fmap (fmap (fmap encode)) Environment.lookupEnv
where
encode = Text.Encoding.encodeUtf8 . Text.pack
{-| This initializes the top-level `Context` using the @TRACEPARENT@ \/
@TRACESTATE@ \/ @BAGGAGE@ environment variables (if present) and otherwise
sets it to the empty `Context`
You have to run this command before calling `getTopLevelContext` otherwise
the latter will hang.
-}
initializeTopLevelContext
:: PackageName
-> IO ()
initializeTopLevelContext packageName = do
span <- getTopLevelSpan packageName
context <- getTopLevelBaggage
let contextWithSpan = Context.insertSpan span context
_ <- MVar.tryPutMVar topLevelContextMVar contextWithSpan
_ <- MVar.tryPutMVar topLevelSpanMapMVar =<< newSpanMap
return ()
-- | Access the top-level `Context` computed by `initializeTopLevelContext`
getTopLevelContext :: IO Context
getTopLevelContext = MVar.readMVar topLevelContextMVar
{-| This is used for communicating between `GHC.Plugins.driverPlugin` and
`GHC.Plugins.installCoreToDos`, because only `GHC.Plugins.driverPlugin` has
access to the full module graph, but there isn't a good way within the
`GHC.Plugins.Plugin` API to share that information with the rest of the
plugin other than a global variable.
-}
rootModuleNamesMVar :: MVar (Set Text)
rootModuleNamesMVar = Unsafe.unsafePerformIO MVar.newEmptyMVar
{-# NOINLINE rootModuleNamesMVar #-}
{-| Set the root module names (computed by `GHC.Plugins.driverPlugin`)
You have to run this command before calling `isRootModule` otherwise
the latter will hang.
-}
setRootModuleNames :: [String] -> IO ()
setRootModuleNames rootModuleNames = do
let set = Set.fromList (map Text.pack rootModuleNames)
_ <- MVar.tryPutMVar rootModuleNamesMVar set
pure ()
-- | Check if a module is one of the root modules
isRootModule :: String -> IO Bool
isRootModule moduleName = do
rootModuleNames <- MVar.readMVar rootModuleNamesMVar
pure (Set.member (Text.pack moduleName) rootModuleNames)
-- | Flush all metrics
flush :: IO ()
flush = do
_ <- Trace.Core.forceFlushTracerProvider tracerProvider Nothing
-- We can't check the result yet because
-- `FlushResult` is not exported by
-- `hs-opentelemetry-api`
--
-- https://github.com/iand675/hs-opentelemetry/pull/96
_ <- Trace.Core.forceFlushTracerProvider tracerProvider Nothing
pure ()
-- | Returns 'True' if the plugin should create spans for module passes in
-- compilation. Examples would be Simplifier, any other plugin execution,
-- etc.
getPluginShouldRecordPasses :: IO Bool
getPluginShouldRecordPasses = do
maybeRecordPasses <- Environment.lookupEnv "OTEL_GHC_PLUGIN_RECORD_PASSES"
pure $ Maybe.fromMaybe False do
recordPasses <- maybeRecordPasses
pure $ Text.toLower (Text.pack recordPasses) `elem` ["true", "t"]
-- | Flush metrics if we're compiling one of the root modules. This is to
-- work around the fact that we don't have a proper way to finalize the
-- `TracerProvider` (since the finalizer would normally be responsible for
-- flushing any last metrics).
--
-- You might wonder: why don't we end the top-level span here? Well, we
-- don't know which one of the root modules will be the last one to be
-- compiled. However, flushing once per root module is still fine because
-- flushing is safe to run at any time and in practice there will only be
-- a few root modules.
flushMetricsWhenRootModule :: String -> IO ()
flushMetricsWhenRootModule modName = do
isRoot <- isRootModule modName
Monad.when isRoot flush
-- | A concurrently accessible map that can be used to connect a module at
-- beginning of compilation and at the end.
--
-- GHC records the phases of computation in a datatype 'TPhase'. This
-- datatype begins Haskell compilation with the 'T_Hsc' phase. The final
-- phase in compilation is 'T_MergeForeign'. The final phase has a few
-- items: a 'PipeEnv', an 'HscEnv', a 'Filepath' representing the location
-- of the object file for the module, and a list of 'Filepath' that I don't
-- know the purpose of.
--
-- 'T_Hsc' phase carries a 'ModSummary' type, which fortunately includes
-- a 'ms_location' field which has the 'ml_object_file' field. Since this
-- information is present both at the beginning and end, we can use that to
-- associate a 'Trace.Span' with a module's beginning and end, to record
-- the full time in compilation.
data SpanMap = SpanMap
{ fromObjectFile :: StmMap.Map FilePath SpanRelease
, fromModuleName :: StmMap.Map String SpanRelease
}
-- | A 'Trace.Span' coupled with the action to delete the 'Span' from the
-- 'SpanMap'.
data SpanRelease = SpanRelease
{ spanReleaseSpan :: Trace.Span
, spanReleaseAction :: STM.STM ()
, spanReleaseModuleName :: String
}
-- | Create a new empty 'SpanMap'.
newSpanMap :: IO SpanMap
newSpanMap = SpanMap <$> StmMap.newIO <*> StmMap.newIO
-- | Create a 'Span' for the given 'ModSummary' and record it in the
-- 'SpanMap'.
recordModuleStart
:: PackageName
-> FilePath
-- ^ The location of the object file for the module. This should be
-- available through the 'ModSummary' via 'ms_location' and
-- 'ml_obj_file'
-> String
-- ^ A string representing the name of the module.
-> IO ()
recordModuleStart packageName modObjectLocation modName = do
spanMap <- MVar.readMVar topLevelSpanMapMVar
context <- getTopLevelContext
span_ <- Trace.createSpan tracer context (Text.pack modName) Trace.defaultSpanArguments
{ attributes = HashMap.fromList
[ ("packageName", Trace.Core.toAttribute packageName)
]
}
let spanRelease =
SpanRelease
{ spanReleaseSpan =
span_
, spanReleaseAction = do
StmMap.delete modObjectLocation (fromObjectFile spanMap)
StmMap.delete modName (fromModuleName spanMap)
, spanReleaseModuleName =
modName
}
STM.atomically do
StmMap.insert spanRelease modObjectLocation (fromObjectFile spanMap)
StmMap.insert spanRelease modName (fromModuleName spanMap)
-- | Given a 'Plugins.Module' and a function that provides
-- a 'Trace.Context', this function modifies the 'Trace.Context' to have
-- the parent span of the module.
modifyContextWithParentSpan
:: String
-> IO Context.Context
-> IO Context.Context
modifyContextWithParentSpan module_ getContext = do
mspan <- getSpanForModule module_
let insertSpan context =
maybe context (`Context.insertSpan` context) mspan
fmap insertSpan getContext
-- | Retrieve the 'Trace.Span' for a given 'Plugins.Module', if one has
-- been recorded.
getSpanForModule
:: String
-> IO (Maybe Span)
getSpanForModule moduleNameString = do
spanMap <- MVar.readMVar topLevelSpanMapMVar
STM.atomically do
fmap spanReleaseSpan <$> do
StmMap.lookup moduleNameString $ fromModuleName spanMap
recordModuleEnd
:: String
-- ^ This should be either the module name *or* the object file
-- location.
-> IO ()
recordModuleEnd moduleIdentifier = do
spanMap <- MVar.readMVar topLevelSpanMapMVar
mspan <- STM.atomically do
liftA2
(<|>)
do StmMap.lookup moduleIdentifier (fromObjectFile spanMap)
do StmMap.lookup moduleIdentifier (fromModuleName spanMap)
Monad.forM_ mspan \SpanRelease {..} -> do
Trace.endSpan spanReleaseSpan Nothing
STM.atomically spanReleaseAction
flushMetricsWhenRootModule spanReleaseModuleName
-- | The name of the package that is being compiled.
newtype PackageName = PackageName
{ unPackageName :: Text
}
deriving newtype Trace.Core.ToAttribute