{- | The metric-recording ports: the abstract interfaces the core serve path and
mirror worker record through, decoupled from any telemetry backend.

"Ecluse.Core.Telemetry.Metrics" defines /what/ the @ecluse.*@ catalogue is (the names
and the closed set of bounded labels). This module defines the __recording interfaces__
over that catalogue as records of @IO@ functions (the Handle pattern, as
"Ecluse.Core.Registry" and "Ecluse.Core.Queue" use): one field per signal a consumer
emits, each taking only the bounded label values its metric carries. A consumer records
through its port and never names an OpenTelemetry instrument; the application supplies
the OTel-backed implementations behind them (see @Ecluse.Telemetry.Instruments@). A test
supplies an inert or recording double.

Two ports are defined: 'MetricsPort' for the serve path (serve decisions, the rule gate,
the data-plane upstream fetch, the metadata cache, and mirror enqueue) and
'WorkerMetricsPort' for the mirror worker (jobs processed, publish latency). The
credential signals stay in the application instrument set; each port carries exactly the
signals its consumer emits.
-}
module Ecluse.Core.Telemetry.Record (
    -- * The serve-path recording port
    MetricsPort (..),

    -- * The worker recording port
    WorkerMetricsPort (..),

    -- * Timing
    timedSeconds,
) where

import GHC.Clock (getMonotonicTime)

import Ecluse.Core.Telemetry.Metrics (
    CacheResult,
    Cause,
    Decision,
    MirrorResult,
    ReasonClass,
    StatusClass,
    Tier,
    Upstream,
 )

{- | The metric-recording port -- a record of functions over a backend whose closure
captures its instruments. Each field records one @ecluse.*@ signal under exactly the
bounded labels that signal carries; the closed label vocabularies come from
"Ecluse.Core.Telemetry.Metrics", so the bounded-cardinality discipline is enforced at
the call site by the types. All fields return 'IO', so a backend (and the core code
recording through it) stays decoupled from the application's effect stack.
-}
data MetricsPort = MetricsPort
    { MetricsPort -> Decision -> IO ()
mpServeDecision :: Decision -> IO ()
    -- ^ Record one serve decision (@ecluse.serve.decision@): admit, deny, or unavailable.
    , MetricsPort -> Int -> IO ()
mpServeAdmissionInFlight :: Int -> IO ()
    -- ^ Record a change (+1 or -1) to in-flight metadata parses (@ecluse.serve.admission.in_flight@).
    , MetricsPort -> IO ()
mpServeAdmissionQueued :: IO ()
    -- ^ Record one admission that waited for a slot before proceeding (@ecluse.serve.admission.queued@).
    , MetricsPort -> Maybe Text -> ReasonClass -> IO ()
mpRuleDenial :: Maybe Text -> ReasonClass -> IO ()
    {- ^ Record one rule denial (@ecluse.rule.denials@) by reason class and, for a
    policy denial, the deciding rule. A non-policy refusal carries no rule.
    -}
    , MetricsPort -> Tier -> Double -> IO ()
mpRuleEvalDuration :: Tier -> Double -> IO ()
    -- ^ Record a rule-evaluation latency sample (@ecluse.rule.eval.duration@) by tier.
    , MetricsPort -> Cause -> IO ()
mpRuleEffectfulFailure :: Cause -> IO ()
    -- ^ Record one effectful-rule failure (@ecluse.rule.effectful.failures@) by cause.
    , MetricsPort -> Upstream -> StatusClass -> Double -> IO ()
mpUpstreamFetch :: Upstream -> StatusClass -> Double -> IO ()
    {- ^ Record an upstream metadata-fetch latency sample
    (@ecluse.upstream.fetch.duration@) by upstream and the response's status class.
    -}
    , MetricsPort -> Upstream -> Cause -> IO ()
mpUpstreamFetchError :: Upstream -> Cause -> IO ()
    {- ^ Record one upstream metadata-fetch error (@ecluse.upstream.fetch.errors@) by
    upstream and the bounded cause.
    -}
    , MetricsPort -> CacheResult -> IO ()
mpCacheRequest :: CacheResult -> IO ()
    {- ^ Record one metadata-cache lookup (@ecluse.metadata_cache.requests@) as a hit
    or a miss.
    -}
    , MetricsPort -> Int -> IO ()
mpCacheEntries :: Int -> IO ()
    -- ^ Record the metadata cache's current occupancy (@ecluse.metadata_cache.entries@).
    , MetricsPort -> Int -> IO ()
mpCacheResidentBytes :: Int -> IO ()
    {- ^ Record the full-packument metadata cache's resident bytes
    (@ecluse.metadata_cache.resident_bytes@).
    -}
    , MetricsPort -> Int -> IO ()
mpVersionCacheResidentBytes :: Int -> IO ()
    {- ^ Record the single-version metadata cache's resident bytes
    (@ecluse.metadata_cache.version.resident_bytes@).
    -}
    , MetricsPort -> Int -> IO ()
mpAssembledCacheResidentBytes :: Int -> IO ()
    {- ^ Record the assembled-representation store's resident bytes
    (@ecluse.metadata_cache.assembled.resident_bytes@).
    -}
    , MetricsPort -> IO ()
mpMirrorEnqueued :: IO ()
    {- ^ Record one mirror job accepted for enqueue (@ecluse.mirror.enqueued@) -- the
    serve path's hand-off to the enqueue buffer, not the backend write.
    -}
    , MetricsPort -> IO ()
mpMirrorEnqueueFailure :: IO ()
    {- ^ Record one mirror enqueue failure (@ecluse.mirror.enqueue.failures@) -- a
    refused hand-off or a failed backend delivery.
    -}
    }

{- | The mirror worker's metric-recording port -- the worker analogue of 'MetricsPort',
kept a separate record so the worker records exactly its own signals and the serve path
exactly its own (the two consumers share no field). Both fields return 'IO', so the
worker loop records through the port without naming a telemetry backend; the application
supplies the OTel-backed implementation (see @Ecluse.Telemetry.Instruments@) and a test
an inert or recording double.
-}
data WorkerMetricsPort = WorkerMetricsPort
    { WorkerMetricsPort -> MirrorResult -> IO ()
wmpMirrorJobProcessed :: MirrorResult -> IO ()
    {- ^ Record one processed mirror job (@ecluse.mirror.jobs.processed@) by its
    terminal result (published, or failed).
    -}
    , WorkerMetricsPort -> Double -> IO ()
wmpMirrorPublishDuration :: Double -> IO ()
    -- ^ Record one mirror publish-latency sample (@ecluse.mirror.publish.duration@).
    }

{- | Run an action and return its result alongside the wall-clock seconds it took,
measured on the monotonic clock so a system-clock step never yields a negative or
absurd duration. The seconds are what the latency histograms record (through
'mpRuleEvalDuration' \/ 'mpUpstreamFetch'). Pure of any backend, so it lives beside the
port the durations feed.
-}
timedSeconds :: (MonadIO m) => m a -> m (a, Double)
timedSeconds :: forall (m :: * -> *) a. MonadIO m => m a -> m (a, Double)
timedSeconds m a
action = do
    start <- IO Double -> m Double
forall a. IO a -> m a
forall (m :: * -> *) a. MonadIO m => IO a -> m a
liftIO IO Double
getMonotonicTime
    result <- action
    end <- liftIO getMonotonicTime
    pure (result, end - start)