packages feed

e11y-otel-0.1.0.0: src/Observe/Event/Backend/OpenTelemetry.hs

-- Copyright 2024 Shea Levy
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UnicodeSyntax #-}

{- |
Description : 'EventBackend' rendering 'Observe.Event.Event's as OpenTelemetry traces
Copyright   : Copyright 2024 Shea Levy.
License     : Apache-2.0
Maintainer  : shea@shealevy.com
-}
module Observe.Event.Backend.OpenTelemetry
  ( TracerEventBackend (..)
  , RenderOTel
  , OTelRendered (..)
  )
where

import Control.Monad.IO.Class
import Data.Functor.Parametric
import Data.HashMap.Strict as HashMap
import Data.Maybe
import Data.Text (Text, pack)
import Observe.Event.Backend
import OpenTelemetry.Context as Context
import OpenTelemetry.Context.ThreadLocal
import OpenTelemetry.Trace.Core

{- | An 'EventBackend' built on a 'Tracer'.

When no explicit parent is set, the backend will try to find a parent in the "OpenTelemetry.Context.ThreadLocal" 'Context'.
However, it will never update that 'Context', as the primitive 'EventBackend' API has no way to determine if it's being
consumed in a scoped context or one allowing for general interleaving.

When possible, events created with 'Observe.Event.instantEvent' will use the span event API. However, this requires a parent event
(explicitly specified or found in the thread-local 'Context'), so the backend will fallback to creating and 'finalize'ing a new
'Span'. If a span event is created, the resulting 'Observe.Event.eventReference' will be to its parent, as span events cannot be parents/links.
Span events do not allow for non-parent links, so any @causes@ are dropped; in the future, we may either add them as custom
'Attribute's or fall back to a full span if any are specified.

Event t'Link's are currently not given any attributes. In the future, arbitrary link metadata could be added to the core 'EventBackend'
API, in which case we could add a renderer for the link metadata type.

The underlying 'Tracer' is responsible for timestamping.

Exceptions are 'recordException'ed without any custom attributes. In the future, an @Exception -> HashMap Text Attribute@
argument could be added, or arbitrary exception metadata added to 'Observe.Event.finalizeEvent'.
-}
data TracerEventBackend selector = TracerEventBackend
  { tracer ∷ !Tracer
  -- ^ The 'Tracer' from @hs-opentelemetry-api@.
  --
  -- See the [hs-opentelemetry-sdk intialization docs](https://hackage.haskell.org/package/hs-opentelemetry-sdk#initialization) for
  -- the typical way of getting a 'Tracer' in your application.
  , render ∷ !(RenderOTel selector)
  -- ^ The domain-specific logic for translating 'Observe.Event.Event's rooted
  -- in a given [selector](https://hackage.haskell.org/package/e11y/docs/Observe-Event.html#g:selectorAndField).
  }

{- | The domain-specific logic for translating 'Observe.Event.Event's rooted
in a given [selector](https://hackage.haskell.org/package/e11y/docs/Observe-Event.html#g:selectorAndField).
-}
type RenderOTel selector = ∀ field. Selectors selector field → OTelRendered field

{- | The domain-specific logic for translating a specific 'Observe.Event.Event'
with a given [field](https://hackage.haskell.org/package/e11y/docs/Observe-Event.html#g:selectorAndField)
type.
-}
data OTelRendered field = OTelRendered
  { eventName ∷ !Text
  -- ^ The name of the event.
  --
  -- See the "span name" section of the [open-telemetry span documentation](https://opentelemetry.io/docs/reference/specification/trace/api/#span).
  , eventKind ∷ !SpanKind
  -- ^ The kind of span to create.
  --
  -- See the [SpanKind](https://opentelemetry.io/docs/reference/specification/trace/api/#spankind) specification.
  , renderField ∷ !(field → HashMap Text Attribute)
  -- ^ Render a field to a set of span [attributes](https://opentelemetry.io/docs/reference/specification/common/#attribute).
  --
  -- Note especially the [attribute naming guidelines](https://opentelemetry.io/docs/reference/specification/common/attribute-naming/).
  }

instance EventBackend (TracerEventBackend selector) where
  type BackendEvent (TracerEventBackend selector) = TracerEventBackendEvent
  type RootSelector (TracerEventBackend selector) = selector

instance (MonadIO m, ParametricFunctor m) ⇒ EventBackendIn m (TracerEventBackend selector) where
  newEvent be params = do
    ctx ← fromMaybe Context.empty <$> lookupContext
    let ctx' = maybe ctx (`insertSpan` ctx) params.parent
        rendered = render be params.selectors
    links ← traverse (fmap (`NewLink` HashMap.empty) . getSpanContext) params.causes
    otelSpan ←
      createSpanWithoutCallStack be.tracer ctx' rendered.eventName $
        SpanArguments
          { kind = rendered.eventKind
          , attributes = foldMap (renderField rendered) params.initialFields
          , links
          , startTime = Nothing
          }
    pure $ TracerEventBackendEvent{otelSpan, renderField' = renderField rendered}

  newInstantEvent be params = case params.parent of
    Nothing → do
      m_ctx ← lookupContext
      case m_ctx >>= lookupSpan of
        Just s → newInstantEvent be $ params{parent = Just s}
        Nothing → do
          ev ← newEvent be params
          finalize ev Nothing
          pure $ reference ev
    Just s → do
      let rendered = render be params.selectors
      addEvent s $
        NewEvent
          { newEventName = rendered.eventName
          , newEventAttributes = foldMap (renderField rendered) params.initialFields
          , newEventTimestamp = Nothing
          }
      pure s

data TracerEventBackendEvent field = TracerEventBackendEvent
  { renderField' ∷ !(field → HashMap Text Attribute)
  , otelSpan ∷ Span
  }

instance Observe.Event.Backend.Event TracerEventBackendEvent where
  type EventReference TracerEventBackendEvent = Span
  reference = (.otelSpan)

instance (MonadIO m, ParametricFunctor m) ⇒ EventIn m TracerEventBackendEvent where
  finalize ev err = do
    case err of
      Just e → do
        recordException ev.otelSpan (singleton "exception.escaped" (toAttribute True)) Nothing e
        setStatus ev.otelSpan . Error . pack $ show e
      Nothing → setStatus ev.otelSpan Ok
    endSpan ev.otelSpan Nothing
  addField ev = addAttributes ev.otelSpan . renderField' ev