diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,17 @@
+# Changelog for hs-opentelemetry-instrumentation-ghc-metrics
+
+## 1.0.0.0 - 2026-05-29
+
+- Promoted to 1.0.0.0 for the hs-opentelemetry 1.0 release.
+
+## 0.1.0.0
+
+- Initial release.
+- `OpenTelemetry.Instrumentation.GHCMetrics`: registers GHC RTS statistics as
+  `process.runtime.ghc.*` metrics (requires `+RTS -T`).
+- `OpenTelemetry.Instrumentation.ProcessMetrics`: registers OS-level process metrics
+  following OTel semantic conventions (`process.cpu.time`, `process.memory.usage`,
+  `process.disk.io`, etc.). Linux-only metrics (page faults, context switches,
+  file descriptor count, thread count) require `/proc` filesystem.
+- Attribute keys follow OTel semantic conventions: `cpu.mode`, `disk.io.direction`,
+  `system.paging.fault.type`, `process.context_switch.type`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Ian Duncan (c) 2021-2026
+
+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/cbits/process_metrics.c b/cbits/process_metrics.c
new file mode 100644
--- /dev/null
+++ b/cbits/process_metrics.c
@@ -0,0 +1,15 @@
+#if defined(__APPLE__)
+#include <mach/mach.h>
+#include <stdint.h>
+
+int64_t hs_otel_get_rss(void) {
+    struct mach_task_basic_info info;
+    mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
+    kern_return_t kr = task_info(mach_task_self(),
+                                MACH_TASK_BASIC_INFO,
+                                (task_info_t)&info,
+                                &count);
+    if (kr != KERN_SUCCESS) return 0;
+    return (int64_t)info.resident_size;
+}
+#endif
diff --git a/hs-opentelemetry-instrumentation-ghc-metrics.cabal b/hs-opentelemetry-instrumentation-ghc-metrics.cabal
new file mode 100644
--- /dev/null
+++ b/hs-opentelemetry-instrumentation-ghc-metrics.cabal
@@ -0,0 +1,67 @@
+cabal-version: 2.4
+name:          hs-opentelemetry-instrumentation-ghc-metrics
+version:       1.0.0.0
+synopsis:      OpenTelemetry metrics for the GHC runtime (RTS statistics)
+description:
+  Registers observable instruments that report GHC RTS statistics as OpenTelemetry
+  metrics.  Requires the program to be run with @+RTS -T@ (or @-t@, @-s@, etc.)
+  so that 'GHC.Stats.getRTSStats' is enabled.
+  .
+  Metric names follow the @process.runtime.ghc.*@ namespace convention.
+
+category:      OpenTelemetry, Telemetry, Monitoring, Observability
+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:     2024-2026 Ian Duncan, Mercury Technologies
+license:       BSD-3-Clause
+license-file:  LICENSE
+build-type:    Simple
+extra-doc-files: ChangeLog.md
+
+source-repository head
+  type:     git
+  location: https://github.com/iand675/hs-opentelemetry
+
+library
+  exposed-modules:
+    OpenTelemetry.Instrumentation.GHCMetrics
+    OpenTelemetry.Instrumentation.ProcessMetrics
+  hs-source-dirs:   src
+  default-language:  Haskell2010
+  default-extensions:
+    OverloadedStrings
+  ghc-options:       -Wall
+  build-depends:
+      base >= 4.16 && < 5
+    , hs-opentelemetry-api ^>= 1.0
+    , hs-opentelemetry-semantic-conventions >= 1.40 && < 2
+    , text
+
+  if os(darwin)
+    c-sources: cbits/process_metrics.c
+    frameworks: CoreFoundation
+
+  if os(linux)
+    build-depends:
+        bytestring
+      , directory
+
+test-suite hs-opentelemetry-instrumentation-ghc-metrics-test
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  default-language:  Haskell2010
+  default-extensions:
+    OverloadedStrings
+  ghc-options:       -threaded -rtsopts "-with-rtsopts=-T -N"
+  build-depends:
+      base >= 4.16 && < 5
+    , hs-opentelemetry-api ^>= 1.0
+    , hs-opentelemetry-exporter-in-memory ^>= 1.0
+    , hs-opentelemetry-instrumentation-ghc-metrics ^>= 1.0
+    , hs-opentelemetry-sdk ^>= 1.0
+    , hspec >= 2.0
+    , text
+    , vector
diff --git a/src/OpenTelemetry/Instrumentation/GHCMetrics.hs b/src/OpenTelemetry/Instrumentation/GHCMetrics.hs
new file mode 100644
--- /dev/null
+++ b/src/OpenTelemetry/Instrumentation/GHCMetrics.hs
@@ -0,0 +1,284 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : OpenTelemetry.Instrumentation.GHCMetrics
+Copyright   : (c) Ian Duncan, 2021-2026
+License     : BSD-3
+Description : Export GHC runtime metrics as OpenTelemetry metrics
+Stability   : experimental
+
+= Overview
+
+Registers observable instruments that report GHC runtime statistics
+(memory usage, GC counters, mutator vs GC time, thread counts, etc.)
+as OpenTelemetry metrics. These are collected automatically on each
+metric export cycle.
+
+The metric set covers every field in 'GHC.Stats.RTSStats' and
+'GHC.Stats.GCDetails', matching or exceeding the coverage provided
+by @ekg-core@'s @registerGcMetrics@.
+
+= Quick example
+
+@
+import OpenTelemetry.Instrumentation.GHCMetrics (registerGHCMetrics)
+import OpenTelemetry.Metric.Core (getGlobalMeterProvider, getMeter)
+import OpenTelemetry.Trace.Core (instrumentationLibrary)
+
+main :: IO ()
+main = do
+  mp <- getGlobalMeterProvider
+  meter <- getMeter mp (instrumentationLibrary "ghc-metrics" "0.1.0")
+  _handles <- registerGHCMetrics meter
+  -- GHC metrics are now exported alongside your application metrics
+@
+
+= Exported metrics
+
+All instrument names use the @process.runtime.ghc.@ prefix.
+
+== Cumulative counters (from 'GHC.Stats.RTSStats')
+
+* @process.runtime.ghc.allocated_bytes@ (By)
+* @process.runtime.ghc.gc.count@ / @gc.major_count@
+* @process.runtime.ghc.gc.cpu_time@ / @gc.elapsed_time@ (s)
+* @process.runtime.ghc.gc.copied_bytes@ / @gc.par_copied_bytes@ (By)
+* @process.runtime.ghc.gc.cumulative_live_bytes@ (By)
+* @process.runtime.ghc.gc.cumulative_par_balanced_copied_bytes@ (By)
+* @process.runtime.ghc.mutator.cpu_time@ / @mutator.elapsed_time@ (s)
+* @process.runtime.ghc.init.cpu_time@ / @init.elapsed_time@ (s)
+* @process.runtime.ghc.cpu_time@ / @elapsed_time@ (s)
+* @process.runtime.ghc.nonmoving_gc.sync.cpu_time@ / @sync.elapsed_time@ (s)
+* @process.runtime.ghc.nonmoving_gc.cpu_time@ / @nonmoving_gc.elapsed_time@ (s)
+
+== Gauges: high-water marks (from 'GHC.Stats.RTSStats')
+
+* @process.runtime.ghc.memory.max_live_bytes@ (By)
+* @process.runtime.ghc.memory.max_large_objects_bytes@ (By)
+* @process.runtime.ghc.memory.max_compact_bytes@ (By)
+* @process.runtime.ghc.memory.max_slop_bytes@ (By)
+* @process.runtime.ghc.memory.max_mem_in_use_bytes@ (By)
+* @process.runtime.ghc.nonmoving_gc.sync.max_elapsed_time@ (s)
+* @process.runtime.ghc.nonmoving_gc.max_elapsed_time@ (s)
+
+== Gauges: last GC snapshot (from 'GHC.Stats.GCDetails')
+
+* @process.runtime.ghc.memory.live_bytes@ / @memory.heap_size@ (By)
+* @process.runtime.ghc.gc.last.*@ for generation, threads, allocated,
+  large objects, compact, slop, copied, parallel copy stats, sync
+  time, CPU\/elapsed time, and nonmoving GC sync time.
+* @process.runtime.ghc.gc.last.block_fragmentation_bytes@ (GHC 9.6+)
+
+Use 'unregisterObservableCallback' on the returned handles for optional cleanup.
+
+__Note__: requires the program to be started with @+RTS -T@ (runtime
+statistics enabled) or a GHC version that enables them by default.
+If stats are disabled, 'registerGHCMetrics' returns an empty list.
+
+@since 0.1.0.0
+-}
+module OpenTelemetry.Instrumentation.GHCMetrics (
+  registerGHCMetrics,
+) where
+
+import Data.Int (Int64)
+import Data.Text (Text)
+import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)
+import OpenTelemetry.Attributes (emptyAttributes)
+import OpenTelemetry.Metric.Core (
+  AdvisoryParameters,
+  Meter (..),
+  ObservableCallbackHandle (..),
+  ObservableCounter (..),
+  ObservableGauge (..),
+  ObservableResult (..),
+  defaultAdvisoryParameters,
+ )
+
+
+{- | Register all GHC runtime metric instruments on the given 'Meter'.
+
+Returns a list of callback handles that can be used to unregister the
+callbacks (e.g. during shutdown). If RTS stats are not enabled
+(@+RTS -T@ was not passed), returns an empty list and no instruments
+are created.
+
+@since 0.1.0.0
+-}
+registerGHCMetrics :: Meter -> IO [ObservableCallbackHandle]
+registerGHCMetrics m = do
+  enabled <- getRTSStatsEnabled
+  if not enabled
+    then pure []
+    else
+      sequence $
+        concat
+          [ rtsStatsCounters m
+          , rtsStatsGauges m
+          , gcDetailsGauges m
+          ]
+
+
+-- ── RTSStats cumulative counters ─────────────────────────────────────
+
+rtsStatsCounters :: Meter -> [IO ObservableCallbackHandle]
+rtsStatsCounters m =
+  [ obsCounterI64 m "process.runtime.ghc.allocated_bytes" "By" "Total bytes allocated on the GHC heap" $
+      \s -> fromIntegral (allocated_bytes s)
+  , obsCounterI64 m "process.runtime.ghc.gc.count" "{gc}" "Total number of GCs" $
+      \s -> fromIntegral (gcs s)
+  , obsCounterI64 m "process.runtime.ghc.gc.major_count" "{gc}" "Total number of major (oldest generation) GCs" $
+      \s -> fromIntegral (major_gcs s)
+  , obsCounterDbl m "process.runtime.ghc.gc.cpu_time" "s" "Total CPU time spent in GC" $
+      \s -> nsToSec (gc_cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.gc.elapsed_time" "s" "Total wall-clock time spent in GC" $
+      \s -> nsToSec (gc_elapsed_ns s)
+  , obsCounterI64 m "process.runtime.ghc.gc.copied_bytes" "By" "Total bytes copied during GC" $
+      \s -> fromIntegral (copied_bytes s)
+  , obsCounterI64 m "process.runtime.ghc.gc.par_copied_bytes" "By" "Total bytes copied during parallel GCs" $
+      \s -> fromIntegral (par_copied_bytes s)
+  , obsCounterI64 m "process.runtime.ghc.gc.cumulative_live_bytes" "By" "Sum of live bytes across all major GCs" $
+      \s -> fromIntegral (cumulative_live_bytes s)
+  , obsCounterI64 m "process.runtime.ghc.gc.cumulative_par_balanced_copied_bytes" "By" "Sum of balanced parallel-copied bytes across all GCs" $
+      \s -> fromIntegral (cumulative_par_balanced_copied_bytes s)
+  , obsCounterDbl m "process.runtime.ghc.mutator.cpu_time" "s" "Total CPU time spent in the mutator" $
+      \s -> nsToSec (mutator_cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.mutator.elapsed_time" "s" "Total wall-clock time spent in the mutator" $
+      \s -> nsToSec (mutator_elapsed_ns s)
+  , obsCounterDbl m "process.runtime.ghc.init.cpu_time" "s" "Total CPU time used by the init phase" $
+      \s -> nsToSec (init_cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.init.elapsed_time" "s" "Total wall-clock time used by the init phase" $
+      \s -> nsToSec (init_elapsed_ns s)
+  , obsCounterDbl m "process.runtime.ghc.cpu_time" "s" "Total CPU time elapsed since program start" $
+      \s -> nsToSec (cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.elapsed_time" "s" "Total wall-clock time elapsed since program start" $
+      \s -> nsToSec (elapsed_ns s)
+  , obsCounterDbl m "process.runtime.ghc.nonmoving_gc.sync.cpu_time" "s" "CPU time spent in nonmoving GC sync phase" $
+      \s -> nsToSec (nonmoving_gc_sync_cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.nonmoving_gc.sync.elapsed_time" "s" "Wall-clock time spent in nonmoving GC sync phase" $
+      \s -> nsToSec (nonmoving_gc_sync_elapsed_ns s)
+  , obsCounterDbl m "process.runtime.ghc.nonmoving_gc.cpu_time" "s" "Total CPU time used by the nonmoving GC" $
+      \s -> nsToSec (nonmoving_gc_cpu_ns s)
+  , obsCounterDbl m "process.runtime.ghc.nonmoving_gc.elapsed_time" "s" "Total wall-clock time with nonmoving GC active" $
+      \s -> nsToSec (nonmoving_gc_elapsed_ns s)
+  ]
+
+
+-- ── RTSStats gauges (high-water marks) ───────────────────────────────
+
+rtsStatsGauges :: Meter -> [IO ObservableCallbackHandle]
+rtsStatsGauges m =
+  [ obsGaugeI64 m "process.runtime.ghc.memory.max_live_bytes" "By" "Maximum live data seen in the heap (high-water mark)" $
+      \s -> fromIntegral (max_live_bytes s)
+  , obsGaugeI64 m "process.runtime.ghc.memory.max_large_objects_bytes" "By" "Maximum live data in large objects" $
+      \s -> fromIntegral (max_large_objects_bytes s)
+  , obsGaugeI64 m "process.runtime.ghc.memory.max_compact_bytes" "By" "Maximum live data in compact regions" $
+      \s -> fromIntegral (max_compact_bytes s)
+  , obsGaugeI64 m "process.runtime.ghc.memory.max_slop_bytes" "By" "Maximum slop (wasted memory)" $
+      \s -> fromIntegral (max_slop_bytes s)
+  , obsGaugeI64 m "process.runtime.ghc.memory.max_mem_in_use_bytes" "By" "Maximum memory in use by the RTS" $
+      \s -> fromIntegral (max_mem_in_use_bytes s)
+  , obsGaugeDbl m "process.runtime.ghc.nonmoving_gc.sync.max_elapsed_time" "s" "Maximum elapsed time of any nonmoving GC sync phase" $
+      \s -> nsToSec (nonmoving_gc_sync_max_elapsed_ns s)
+  , obsGaugeDbl m "process.runtime.ghc.nonmoving_gc.max_elapsed_time" "s" "Maximum elapsed time of any nonmoving GC cycle" $
+      \s -> nsToSec (nonmoving_gc_max_elapsed_ns s)
+  ]
+
+
+-- ── GCDetails gauges (last GC snapshot) ──────────────────────────────
+
+gcDetailsGauges :: Meter -> [IO ObservableCallbackHandle]
+gcDetailsGauges m =
+  [ obsGaugeI64 m "process.runtime.ghc.memory.live_bytes" "By" "Current live data in the heap (as of last major GC)" $
+      \s -> fromIntegral (gcdetails_live_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.memory.heap_size" "By" "Current memory in use by the RTS" $
+      \s -> fromIntegral (gcdetails_mem_in_use_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.gen" "{gen}" "Generation number of the most recent GC" $
+      \s -> fromIntegral (gcdetails_gen (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.threads" "{thread}" "Number of threads used in the most recent GC" $
+      \s -> fromIntegral (gcdetails_threads (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.allocated_bytes" "By" "Bytes allocated since the previous GC" $
+      \s -> fromIntegral (gcdetails_allocated_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.large_objects_bytes" "By" "Live data in large objects (last GC)" $
+      \s -> fromIntegral (gcdetails_large_objects_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.compact_bytes" "By" "Live data in compact regions (last GC)" $
+      \s -> fromIntegral (gcdetails_compact_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.slop_bytes" "By" "Wasted memory (slop) in the last GC" $
+      \s -> fromIntegral (gcdetails_slop_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.copied_bytes" "By" "Bytes copied during the last GC" $
+      \s -> fromIntegral (gcdetails_copied_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.par_max_copied_bytes" "By" "Max bytes copied by any one thread (last GC)" $
+      \s -> fromIntegral (gcdetails_par_max_copied_bytes (gc s))
+  , obsGaugeI64 m "process.runtime.ghc.gc.last.par_balanced_copied_bytes" "By" "Balanced parallel-copied bytes (last GC)" $
+      \s -> fromIntegral (gcdetails_par_balanced_copied_bytes (gc s))
+  , obsGaugeDbl m "process.runtime.ghc.gc.last.sync_elapsed_time" "s" "Elapsed time for synchronisation before last GC" $
+      \s -> nsToSec (gcdetails_sync_elapsed_ns (gc s))
+  , obsGaugeDbl m "process.runtime.ghc.gc.last.cpu_time" "s" "CPU time used by the last GC" $
+      \s -> nsToSec (gcdetails_cpu_ns (gc s))
+  , obsGaugeDbl m "process.runtime.ghc.gc.last.elapsed_time" "s" "Wall-clock time of the last GC" $
+      \s -> nsToSec (gcdetails_elapsed_ns (gc s))
+  , obsGaugeDbl m "process.runtime.ghc.gc.last.nonmoving_gc_sync_cpu_time" "s" "Nonmoving GC sync CPU time (last GC)" $
+      \s -> nsToSec (gcdetails_nonmoving_gc_sync_cpu_ns (gc s))
+  , obsGaugeDbl m "process.runtime.ghc.gc.last.nonmoving_gc_sync_elapsed_time" "s" "Nonmoving GC sync elapsed time (last GC)" $
+      \s -> nsToSec (gcdetails_nonmoving_gc_sync_elapsed_ns (gc s))
+  ]
+    ++ gcDetailsBlockFragGauge m
+
+
+gcDetailsBlockFragGauge :: Meter -> [IO ObservableCallbackHandle]
+#if MIN_VERSION_base(4,18,0)
+gcDetailsBlockFragGauge m =
+  [ obsGaugeI64 m "process.runtime.ghc.gc.last.block_fragmentation_bytes" "By" "Memory lost to block fragmentation (last GC)" $
+      \s -> fromIntegral (gcdetails_block_fragmentation_bytes (gc s))
+  ]
+#else
+gcDetailsBlockFragGauge _ = []
+#endif
+
+
+-- ── Helpers ──────────────────────────────────────────────────────────
+
+noAdv :: AdvisoryParameters
+noAdv = defaultAdvisoryParameters
+
+
+obsCounterI64
+  :: Meter -> Text -> Text -> Text -> (RTSStats -> Int64) -> IO ObservableCallbackHandle
+obsCounterI64 m name unit desc extract = do
+  oc <- meterCreateObservableCounterInt64 m name (Just unit) (Just desc) noAdv []
+  observableCounterRegisterCallback oc $ \res -> do
+    s <- getRTSStats
+    observe res (extract s) emptyAttributes
+
+
+obsCounterDbl
+  :: Meter -> Text -> Text -> Text -> (RTSStats -> Double) -> IO ObservableCallbackHandle
+obsCounterDbl m name unit desc extract = do
+  oc <- meterCreateObservableCounterDouble m name (Just unit) (Just desc) noAdv []
+  observableCounterRegisterCallback oc $ \res -> do
+    s <- getRTSStats
+    observe res (extract s) emptyAttributes
+
+
+obsGaugeI64
+  :: Meter -> Text -> Text -> Text -> (RTSStats -> Int64) -> IO ObservableCallbackHandle
+obsGaugeI64 m name unit desc extract = do
+  og <- meterCreateObservableGaugeInt64 m name (Just unit) (Just desc) noAdv []
+  observableGaugeRegisterCallback og $ \res -> do
+    s <- getRTSStats
+    observe res (extract s) emptyAttributes
+
+
+obsGaugeDbl
+  :: Meter -> Text -> Text -> Text -> (RTSStats -> Double) -> IO ObservableCallbackHandle
+obsGaugeDbl m name unit desc extract = do
+  og <- meterCreateObservableGaugeDouble m name (Just unit) (Just desc) noAdv []
+  observableGaugeRegisterCallback og $ \res -> do
+    s <- getRTSStats
+    observe res (extract s) emptyAttributes
+
+
+nsToSec :: (Integral a) => a -> Double
+nsToSec ns = fromIntegral ns / 1000000000
+{-# INLINE nsToSec #-}
diff --git a/src/OpenTelemetry/Instrumentation/ProcessMetrics.hs b/src/OpenTelemetry/Instrumentation/ProcessMetrics.hs
new file mode 100644
--- /dev/null
+++ b/src/OpenTelemetry/Instrumentation/ProcessMetrics.hs
@@ -0,0 +1,454 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : OpenTelemetry.Instrumentation.ProcessMetrics
+Copyright   : (c) Ian Duncan, 2021-2026
+License     : BSD-3
+Description : OS-level process metrics following OTel semantic conventions
+Stability   : experimental
+
+Registers observable instruments for standard OS-level process metrics
+(@process.cpu.time@, @process.memory.usage@, @process.uptime@, etc.)
+as defined by the
+<https://opentelemetry.io/docs/specs/semconv/system/process-metrics/ OpenTelemetry semantic conventions for process metrics>.
+
+These complement the GHC-specific runtime metrics in
+"OpenTelemetry.Instrumentation.GHCMetrics" — those cover the Haskell
+runtime internals, while these cover what the OS reports about the
+process.
+
+Also includes @process.runtime.ghc.capability.count@ (number of HEC
+capabilities, analogous to @go.processor.limit@ in the Go SDK).
+
+= Metrics registered
+
+== Standard process metrics
+
+* @process.cpu.time@ — counter (s), with @cpu.mode=user@ and @cpu.mode=system@
+* @process.memory.usage@ — gauge (By), resident set size
+* @process.memory.virtual@ — gauge (By), virtual memory size (Linux only)
+* @process.thread.count@ — gauge, live thread count from @\/proc\/self\/status@ (Linux only)
+* @process.unix.file_descriptor.count@ — gauge, open FD count via @\/proc\/self\/fd@ (Linux only)
+* @process.disk.io@ — counter (By), with @disk.io.direction=read|write@ from @\/proc\/self\/io@ (Linux only)
+* @process.uptime@ — gauge (s), wall-clock time since registration
+* @process.paging.faults@ — counter, with @system.paging.fault.type=minor|major@
+* @process.context_switches@ — counter, with @process.context_switch.type=voluntary|involuntary@
+
+== Haskell-specific
+
+* @process.runtime.ghc.capability.count@ — gauge, number of HEC capabilities
+
+= Data sources
+
+On POSIX (Linux, macOS): @getrusage(2)@ for CPU, page faults, context switches.
+Linux: @\/proc\/self\/status@ for VmRSS, VmSize, and Threads; @\/proc\/self\/fd@ for FD count;
+@\/proc\/self\/io@ for read\/write bytes.
+macOS: @task_info(MACH_TASK_BASIC_INFO)@ for resident size.
+
+@since 0.1.0.0
+-}
+module OpenTelemetry.Instrumentation.ProcessMetrics (
+  registerProcessMetrics,
+) where
+
+import Data.Int (Int64)
+import Data.Text (Text)
+import Data.Word (Word64)
+import Foreign.C.Types (CLong (..))
+import Foreign.Marshal.Alloc (allocaBytes)
+import Foreign.Ptr (Ptr)
+import Foreign.Storable (peekByteOff)
+import GHC.Clock (getMonotonicTimeNSec)
+import GHC.Conc (getNumCapabilities)
+import qualified GHC.Stats as Stats
+import OpenTelemetry.Attributes (
+  Attributes,
+  emptyAttributes,
+  toAttribute,
+  unsafeAttributesFromListIgnoringLimits,
+ )
+import OpenTelemetry.Attributes.Key (unkey)
+import OpenTelemetry.Metric.Core (
+  AdvisoryParameters,
+  Meter (..),
+  ObservableCallbackHandle (..),
+  ObservableCounter (..),
+  ObservableGauge (..),
+  ObservableResult (..),
+  defaultAdvisoryParameters,
+ )
+import qualified OpenTelemetry.SemanticConventions as SC
+import System.IO.Unsafe (unsafePerformIO)
+
+
+#if defined(linux_HOST_OS)
+import Control.Exception (SomeException, catch)
+import qualified Data.ByteString as BS
+import System.Directory (listDirectory)
+#endif
+
+
+{- | Register OS-level process metric instruments on the given 'Meter'.
+
+Returns callback handles for optional cleanup via
+@unregisterObservableCallback@.
+
+@since 0.1.0.0
+-}
+registerProcessMetrics :: Meter -> IO [ObservableCallbackHandle]
+registerProcessMetrics m =
+  sequence $
+    concat
+      [ cpuTimeCounters m
+      , memoryGauges m
+      , uptimeGauge m
+      , pagingFaultsCounters m
+      , contextSwitchCounters m
+      , capabilityGauge m
+      , threadCountGauge m
+      , fdCountGauge m
+      , diskIoCounters m
+      ]
+
+
+-- ── CPU time ────────────────────────────────────────────────────────
+
+cpuTimeCounters :: Meter -> [IO ObservableCallbackHandle]
+cpuTimeCounters m =
+  [ do
+      oc <- meterCreateObservableCounterDouble m "process.cpu.time" (Just "s") (Just "Total CPU seconds broken down by different states") noAdv []
+      observableCounterRegisterCallback oc $ \res -> do
+        ru <- getRUsage
+        observe res (ruUserSec ru) userModeAttr
+        observe res (ruSystemSec ru) systemModeAttr
+  ]
+
+
+-- ── Memory ──────────────────────────────────────────────────────────
+
+memoryGauges :: Meter -> [IO ObservableCallbackHandle]
+memoryGauges m =
+  rssGauge m ++ virtualGauge m
+
+
+rssGauge :: Meter -> [IO ObservableCallbackHandle]
+rssGauge m =
+  [ do
+      og <- meterCreateObservableGaugeInt64 m "process.memory.usage" (Just "By") (Just "The amount of physical memory in use") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        rss <- getResidentBytes
+        observe res rss emptyAttributes
+  ]
+
+#if defined(linux_HOST_OS)
+virtualGauge :: Meter -> [IO ObservableCallbackHandle]
+virtualGauge m =
+  [ do
+      og <- meterCreateObservableGaugeInt64 m "process.memory.virtual" (Just "By") (Just "The amount of committed virtual memory") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        vm <- getVirtualBytes
+        observe res vm emptyAttributes
+  ]
+#else
+virtualGauge :: Meter -> [IO ObservableCallbackHandle]
+virtualGauge _ = []
+#endif
+
+
+-- ── Uptime ──────────────────────────────────────────────────────────
+
+startTimeNs :: Word64
+startTimeNs = unsafePerformIO getMonotonicTimeNSec
+{-# NOINLINE startTimeNs #-}
+
+
+getUptimeSeconds :: IO Double
+getUptimeSeconds =
+  ( do
+      rtsEnabled <- Stats.getRTSStatsEnabled
+      if rtsEnabled
+        then do
+          stats <- Stats.getRTSStats
+          pure $ fromIntegral (Stats.elapsed_ns stats) / 1e9
+        else monoFallback
+  )
+  where
+    monoFallback = do
+      now <- getMonotonicTimeNSec
+      pure $ fromIntegral (now - startTimeNs) / 1e9
+
+
+uptimeGauge :: Meter -> [IO ObservableCallbackHandle]
+uptimeGauge m =
+  [ do
+      og <- meterCreateObservableGaugeDouble m "process.uptime" (Just "s") (Just "The time the process has been running") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        uptimeSec <- getUptimeSeconds
+        observe res uptimeSec emptyAttributes
+  ]
+
+
+-- ── Paging faults ───────────────────────────────────────────────────
+
+pagingFaultsCounters :: Meter -> [IO ObservableCallbackHandle]
+pagingFaultsCounters m =
+  [ do
+      oc <- meterCreateObservableCounterInt64 m "process.paging.faults" (Just "{fault}") (Just "Number of page faults the process has made") noAdv []
+      observableCounterRegisterCallback oc $ \res -> do
+        ru <- getRUsage
+        observe res (ruMinorFaults ru) minorFaultAttr
+        observe res (ruMajorFaults ru) majorFaultAttr
+  ]
+
+
+-- ── Context switches ────────────────────────────────────────────────
+
+contextSwitchCounters :: Meter -> [IO ObservableCallbackHandle]
+contextSwitchCounters m =
+  [ do
+      oc <- meterCreateObservableCounterInt64 m "process.context_switches" (Just "{context_switch}") (Just "Number of times the process has been context switched") noAdv []
+      observableCounterRegisterCallback oc $ \res -> do
+        ru <- getRUsage
+        observe res (ruVoluntaryCSW ru) voluntaryCSWAttr
+        observe res (ruInvoluntaryCSW ru) involuntaryCSWAttr
+  ]
+
+
+-- ── GHC capabilities ───────────────────────────────────────────────
+
+capabilityGauge :: Meter -> [IO ObservableCallbackHandle]
+capabilityGauge m =
+  [ do
+      og <- meterCreateObservableGaugeInt64 m "process.runtime.ghc.capability.count" (Just "{capability}") (Just "Number of GHC HEC capabilities (green thread execution contexts)") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        n <- getNumCapabilities
+        observe res (fromIntegral n) emptyAttributes
+  ]
+
+#if defined(linux_HOST_OS)
+readDirAttr :: Attributes
+readDirAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.disk_io_direction, toAttribute ("read" :: Text))]
+
+
+writeDirAttr :: Attributes
+writeDirAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.disk_io_direction, toAttribute ("write" :: Text))]
+
+
+threadCountGauge :: Meter -> [IO ObservableCallbackHandle]
+threadCountGauge m =
+  [ do
+      og <- meterCreateObservableGaugeInt64 m "process.thread.count" (Just "{thread}") (Just "Process threads count") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        r <- parseProcCountField "Threads:"
+        observe res (maybe 0 id r) emptyAttributes
+  ]
+
+fdCountGauge :: Meter -> [IO ObservableCallbackHandle]
+fdCountGauge m =
+  [ do
+      og <- meterCreateObservableGaugeInt64 m "process.unix.file_descriptor.count" (Just "{file_descriptor}") (Just "Number of unix file descriptors in use by the process") noAdv []
+      observableGaugeRegisterCallback og $ \res -> do
+        n <- countOpenFds
+        observe res n emptyAttributes
+  ]
+
+diskIoCounters :: Meter -> [IO ObservableCallbackHandle]
+diskIoCounters m =
+  [ do
+      oc <- meterCreateObservableCounterInt64 m "process.disk.io" (Just "By") (Just "Disk bytes transferred") noAdv []
+      observableCounterRegisterCallback oc $ \res -> do
+        (r, w) <- getDiskIo
+        observe res r readDirAttr
+        observe res w writeDirAttr
+  ]
+#else
+threadCountGauge :: Meter -> [IO ObservableCallbackHandle]
+threadCountGauge _ = []
+
+fdCountGauge :: Meter -> [IO ObservableCallbackHandle]
+fdCountGauge _ = []
+
+diskIoCounters :: Meter -> [IO ObservableCallbackHandle]
+diskIoCounters _ = []
+#endif
+
+
+-- ── Attribute constants ─────────────────────────────────────────────
+
+userModeAttr :: Attributes
+userModeAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.cpu_mode, toAttribute ("user" :: Text))]
+
+
+systemModeAttr :: Attributes
+systemModeAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.cpu_mode, toAttribute ("system" :: Text))]
+
+
+minorFaultAttr :: Attributes
+minorFaultAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.system_paging_fault_type, toAttribute ("minor" :: Text))]
+
+
+majorFaultAttr :: Attributes
+majorFaultAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.system_paging_fault_type, toAttribute ("major" :: Text))]
+
+
+voluntaryCSWAttr :: Attributes
+voluntaryCSWAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.process_contextSwitch_type, toAttribute ("voluntary" :: Text))]
+
+
+involuntaryCSWAttr :: Attributes
+involuntaryCSWAttr = unsafeAttributesFromListIgnoringLimits [(unkey SC.process_contextSwitch_type, toAttribute ("involuntary" :: Text))]
+
+
+-- ── getrusage FFI ───────────────────────────────────────────────────
+
+data RUsage = RUsage
+  { ruUserSec :: {-# UNPACK #-} !Double
+  , ruSystemSec :: {-# UNPACK #-} !Double
+  , ruMinorFaults :: {-# UNPACK #-} !Int64
+  , ruMajorFaults :: {-# UNPACK #-} !Int64
+  , ruVoluntaryCSW :: {-# UNPACK #-} !Int64
+  , ruInvoluntaryCSW :: {-# UNPACK #-} !Int64
+  }
+
+
+foreign import ccall unsafe "sys/resource.h getrusage"
+  c_getrusage :: Int -> Ptr () -> IO Int
+
+
+-- struct rusage layout on 64-bit POSIX (Linux glibc, macOS):
+--   ru_utime  @ offset 0   (struct timeval: long tv_sec, long tv_usec)
+--   ru_stime  @ offset 16
+--   remaining long fields at 4× long-size intervals
+getRUsage :: IO RUsage
+getRUsage = allocaBytes rusageSize $ \ptr -> do
+  _ <- c_getrusage 0 ptr -- RUSAGE_SELF = 0
+  uSec <- peekByteOff ptr 0 :: IO CLong
+  uUsec <- peekByteOff ptr longSize :: IO CLong
+  sSec <- peekByteOff ptr (2 * longSize) :: IO CLong
+  sUsec <- peekByteOff ptr (3 * longSize) :: IO CLong
+  minflt <- peekByteOff ptr (7 * longSize) :: IO CLong
+  majflt <- peekByteOff ptr (8 * longSize) :: IO CLong
+  nvcsw <- peekByteOff ptr (14 * longSize) :: IO CLong
+  nivcsw <- peekByteOff ptr (15 * longSize) :: IO CLong
+  pure $!
+    RUsage
+      { ruUserSec = timevalToSec uSec uUsec
+      , ruSystemSec = timevalToSec sSec sUsec
+      , ruMinorFaults = fromIntegral minflt
+      , ruMajorFaults = fromIntegral majflt
+      , ruVoluntaryCSW = fromIntegral nvcsw
+      , ruInvoluntaryCSW = fromIntegral nivcsw
+      }
+  where
+    longSize = 8 -- 64-bit platforms (aarch64, x86_64)
+    rusageSize = 18 * longSize
+    timevalToSec :: CLong -> CLong -> Double
+    timevalToSec s us = fromIntegral s + fromIntegral us / 1e6
+{-# INLINE getRUsage #-}
+
+-- ── Memory reading (platform-specific) ──────────────────────────────
+
+#if defined(linux_HOST_OS)
+
+getResidentBytes :: IO Int64
+getResidentBytes = do
+  r <- parseProcField "VmRSS:"
+  pure $ maybe 0 id r
+
+getVirtualBytes :: IO Int64
+getVirtualBytes = do
+  r <- parseProcField "VmSize:"
+  pure $ maybe 0 id r
+
+parseProcField :: BS.ByteString -> IO (Maybe Int64)
+parseProcField label =
+  (do
+    bs <- BS.readFile "/proc/self/status"
+    pure $ extractKbField label bs
+  )
+    `catch` \(_ :: SomeException) -> pure Nothing
+
+parseProcCountField :: BS.ByteString -> IO (Maybe Int64)
+parseProcCountField label =
+  (do
+    bs <- BS.readFile "/proc/self/status"
+    pure $ extractCountField label bs
+  )
+    `catch` \(_ :: SomeException) -> pure Nothing
+
+extractCountField :: BS.ByteString -> BS.ByteString -> Maybe Int64
+extractCountField label bs = go (BS.lines bs)
+  where
+    go [] = Nothing
+    go (line : rest)
+      | label `BS.isPrefixOf` line =
+          let stripped = BS.dropWhile isSpace (BS.drop (BS.length label) line)
+              digits = BS.takeWhile isDigit stripped
+          in case parseDecimal digits of
+              Just n -> Just n
+              Nothing -> go rest
+      | otherwise = go rest
+    isSpace w = w == 0x20 || w == 0x09
+    isDigit w = w >= 0x30 && w <= 0x39
+
+extractKbField :: BS.ByteString -> BS.ByteString -> Maybe Int64
+extractKbField label bs = go (BS.lines bs)
+  where
+    go [] = Nothing
+    go (line : rest)
+      | label `BS.isPrefixOf` line =
+          let stripped = BS.dropWhile isSpace (BS.drop (BS.length label) line)
+              digits = BS.takeWhile isDigit stripped
+          in case parseDecimal digits of
+              Just kb -> Just (kb * 1024) -- VmRSS/VmSize report in kB
+              Nothing -> go rest
+      | otherwise = go rest
+    isSpace w = w == 0x20 || w == 0x09
+    isDigit w = w >= 0x30 && w <= 0x39
+
+parseDecimal :: BS.ByteString -> Maybe Int64
+parseDecimal bs
+  | BS.null bs = Nothing
+  | otherwise = Just $ BS.foldl' (\acc w -> acc * 10 + fromIntegral (w - 0x30)) 0 bs
+
+countOpenFds :: IO Int64
+countOpenFds =
+  ( do
+      entries <- listDirectory "/proc/self/fd"
+      pure $! fromIntegral (length entries)
+  )
+    `catch` \(_ :: SomeException) -> pure 0
+
+getDiskIo :: IO (Int64, Int64)
+getDiskIo =
+  ( do
+      bs <- BS.readFile "/proc/self/io"
+      let rb = maybe 0 id $ extractCountField "read_bytes:" bs
+          wb = maybe 0 id $ extractCountField "write_bytes:" bs
+      pure (rb, wb)
+  )
+    `catch` \(_ :: SomeException) -> pure (0, 0)
+
+#elif defined(darwin_HOST_OS)
+
+foreign import ccall unsafe "hs_otel_get_rss"
+  c_get_rss :: IO Int64
+
+getResidentBytes :: IO Int64
+getResidentBytes = c_get_rss
+
+#else
+
+-- Fallback for unsupported platforms
+getResidentBytes :: IO Int64
+getResidentBytes = pure 0
+
+#endif
+
+
+-- ── Helpers ─────────────────────────────────────────────────────────
+
+noAdv :: AdvisoryParameters
+noAdv = defaultAdvisoryParameters
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,208 @@
+{- FOURMOLU_DISABLE -}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import qualified Data.Text as T
+import qualified Data.Vector as V
+import OpenTelemetry.Exporter.Metric (
+  MetricExport (..),
+  ResourceMetricsExport (..),
+  ScopeMetricsExport (..),
+ )
+import OpenTelemetry.Instrumentation.GHCMetrics (registerGHCMetrics)
+import OpenTelemetry.Instrumentation.ProcessMetrics (registerProcessMetrics)
+import OpenTelemetry.Internal.Common.Types (instrumentationLibrary)
+import OpenTelemetry.MeterProvider (
+  collectResourceMetrics,
+  createMeterProvider,
+  defaultSdkMeterProviderOptions,
+ )
+import OpenTelemetry.Metric.Core (getMeter)
+import OpenTelemetry.Resource (emptyMaterializedResources)
+import Test.Hspec
+
+
+expectedBaseCount :: Int
+#if MIN_VERSION_base(4,18,0)
+expectedBaseCount = 43
+#else
+expectedBaseCount = 42
+#endif
+
+
+-- process.cpu.time, process.memory.usage, process.uptime,
+-- process.paging.faults, process.context_switches,
+-- process.runtime.ghc.capability.count = 6 callbacks
+-- On Linux: +1 process.memory.virtual, +1 process.thread.count,
+-- +1 process.unix.file_descriptor.count, +1 process.disk.io = 10
+expectedProcessCount :: Int
+#if defined(linux_HOST_OS)
+expectedProcessCount = 10
+#else
+expectedProcessCount = 6
+#endif
+
+
+main :: IO ()
+main = hspec spec
+
+
+spec :: Spec
+spec = do
+  describe "GHCMetrics" $ do
+    it "registers all observable instruments" $ do
+      (provider, _env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.ghc-metrics" "0.1.0")
+      handles <- registerGHCMetrics m
+      length handles `shouldBe` expectedBaseCount
+
+    it "produces metrics with process.runtime.ghc. prefix" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.ghc-metrics" "0.1.0")
+      _ <- registerGHCMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` all (T.isPrefixOf "process.runtime.ghc.")
+      length names `shouldBe` expectedBaseCount
+
+    it "reports allocated_bytes > 0" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.ghc-metrics" "0.1.0")
+      _ <- registerGHCMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.runtime.ghc.allocated_bytes"
+
+    it "reports expected counter and gauge names" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.ghc-metrics" "0.1.0")
+      _ <- registerGHCMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.count"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.par_copied_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.cumulative_live_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.mutator.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.init.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.elapsed_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.nonmoving_gc.sync.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.nonmoving_gc.cpu_time"
+
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.max_live_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.max_large_objects_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.max_compact_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.max_slop_bytes"
+
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.live_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.memory.heap_size"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.gen"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.threads"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.allocated_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.slop_bytes"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.cpu_time"
+      names `shouldSatisfy` elem "process.runtime.ghc.gc.last.nonmoving_gc_sync_cpu_time"
+
+  describe "ProcessMetrics" $ do
+    it "registers expected number of callbacks" $ do
+      (provider, _env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      handles <- registerProcessMetrics m
+      length handles `shouldBe` expectedProcessCount
+
+    it "produces process.cpu.time metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.cpu.time"
+
+    it "produces process.memory.usage metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.memory.usage"
+
+    it "produces process.uptime metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.uptime"
+
+    it "produces process.paging.faults metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.paging.faults"
+
+    it "produces process.context_switches metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.context_switches"
+
+    it "produces process.runtime.ghc.capability.count metric" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.runtime.ghc.capability.count"
+
+#if defined(linux_HOST_OS)
+    it "produces process.thread.count metric on Linux" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.thread.count"
+
+    it "produces process.unix.file_descriptor.count metric on Linux" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.unix.file_descriptor.count"
+
+    it "produces process.disk.io metric on Linux" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.disk.io"
+#endif
+
+    it "reports non-negative uptime" $ do
+      (provider, env) <- createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions
+      m <- getMeter provider (instrumentationLibrary "test.process-metrics" "0.1.0")
+      _ <- registerProcessMetrics m
+      batches <- collectResourceMetrics env
+      let names = concatMap extractMetricNames batches
+      names `shouldSatisfy` elem "process.uptime"
+
+
+extractMetricNames :: ResourceMetricsExport -> [T.Text]
+extractMetricNames rme =
+  concatMap scopeNames (V.toList (resourceMetricsScopes rme))
+  where
+    scopeNames sme = fmap metricExportName (V.toList (scopeMetricsExports sme))
+    metricExportName (MetricExportSum {mesName = n}) = n
+    metricExportName (MetricExportGauge {megName = n}) = n
+    metricExportName (MetricExportHistogram {mehName = n}) = n
+    metricExportName (MetricExportExponentialHistogram {meehName = n}) = n
