morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
observability.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <cstdint>
6#include <functional>
7#include <memory>
8#include <mutex>
9#include <span>
10#include <string_view>
11#include <utility>
12
13namespace morph::observe {
14
15/// @brief Kinds of metric observation the framework's hot paths can emit.
16enum class Metric : std::uint8_t {
17 /// @brief One dispatch's wall time in milliseconds (model strand).
18 executeLatencyMs,
19 /// @brief Gauge: concurrent in-flight executes.
20 executeInFlight,
21 /// @brief Counter: executes that resolved via an error path.
22 executeErrors,
23 /// @brief Counter: register calls.
24 registerCount,
25 /// @brief Counter: deregister calls.
26 deregisterCount,
27 /// @brief Gauge: `IOfflineQueue` pending items at drain.
28 queueDepth,
29 /// @brief Counter: `ReconnectCoordinator::onOnline` `tryReconnect` attempts.
30 reconnectAttempts,
31 /// @brief Counter, tagged by outcome: `ReconnectCoordinator::onOnline` results.
32 reconnectOutcome,
33 /// @brief Counter: an `IOfflineQueue::enqueue()` call rejected because the
34 /// queue was at its configured `maxDepth()`.
35 queueOverflow,
36 /// @brief Counter: an envelope handed to `RemoteServer::post` dropped
37 /// because it exceeds `wire::kMaxEnvelopeBytes`.
38 eventOversize,
39};
40
41/// @brief One metric observation delivered to the installed `MetricSink`.
43 /// @brief Which metric this observation is for.
44 Metric metric;
45 /// @brief The observed value (a duration, a count, or a gauge level).
46 double value;
47 /// @brief Dimensions for this observation (e.g. `modelType`, `actionType`,
48 /// `outcome`), without baking them into `Metric` itself. Empty for
49 /// metrics that carry no dimensions.
50 std::span<const std::pair<std::string_view, std::string_view>> tags;
51};
52
53/// @brief Sink signature for metric observations. Installed once via `setMetricSink`.
54using MetricSink = std::function<void(const MetricEvent&)>;
55
56/// @brief Opaque identifier for one trace span, returned by `TraceSink::beginSpan`.
57///
58/// The value `0` is reserved as the "no span" sentinel: `detail::beginSpan`
59/// returns it when no trace sink is installed, and `detail::endSpan` treats it
60/// as a no-op, so call sites never need to branch on whether tracing is enabled.
61using SpanId = std::uint64_t;
62
63/// @brief Host-supplied hooks bracketing one dispatch, for distributed tracing.
64///
65/// Both members must be set for the sink to take effect (see `setTraceSink`) —
66/// a sink with only one of the two is treated as not installed.
67struct TraceSink {
68 /// @brief Called when an execute begins dispatch (on the model strand).
69 ///
70 /// @p requestId is `Context::requestId` (`session.hpp`), the caller's
71 /// correlation id (empty if unset). Returns a `SpanId` the framework passes
72 /// back to `endSpan`.
73 std::function<SpanId(std::string_view requestId, std::string_view modelType, std::string_view actionType)>
75 /// @brief Called when the dispatch resolves. @p ok is `true` for a
76 /// successful (`ok`/resolved-value) outcome, `false` for an error.
77 std::function<void(SpanId spanId, bool ok)> endSpan;
78};
79
80namespace detail {
81
82/// @brief Process-wide observability state: the metric sink and the trace
83/// sink, each with its own mutex and lock-free "enabled" atomic —
84/// mirrors `morph::log::detail::LogState` (`logger.hpp`).
85struct ObserveState {
86 /// @brief Metric sink, guarded by `metricMtx`. Default: none (no-op).
87 ///
88 /// Held behind a `shared_ptr` so an emitter can take a cheap reference-count
89 /// copy under the lock and then *release the lock before invoking it* — see
90 /// `emitMetric` for why calling a host sink under the mutex is not safe.
91 std::shared_ptr<const MetricSink> metricSink;
92 /// @brief Lock-free "is a metric sink installed?" flag, read by
93 /// `emitMetric` before constructing any `MetricEvent`.
94 std::atomic<bool> metricsOn{false};
95 /// @brief Guards installation and retrieval of `metricSink` — not its invocation.
96 std::mutex metricMtx;
97
98 /// @brief Trace sink, guarded by `traceMtx`. Default: none (no-op).
99 std::shared_ptr<const TraceSink> traceSink;
100 /// @brief Lock-free "is a (complete) trace sink installed?" flag.
101 std::atomic<bool> traceOn{false};
102 /// @brief Guards installation and retrieval of `traceSink` — not its invocation.
103 std::mutex traceMtx;
104};
105
106/// @brief Returns the process-wide `ObserveState` singleton (Meyers singleton,
107/// thread-safe first-use construction — mirrors `morph::log::detail::logState`).
108inline ObserveState& observeState() {
109 static ObserveState state;
110 return state;
111}
112
113/// @brief Emits @p metric if a sink is installed; a single relaxed atomic load
114/// (no mutex, no `MetricEvent` construction) if not.
115///
116/// @par Why the sink runs outside the lock, inside a `catch (...)`
117/// Both properties are load-bearing, because a `MetricSink` is host code the
118/// framework calls from its hot paths:
119///
120/// - **Outside the lock.** `std::mutex` is not recursive, so a sink that emits a
121/// metric of its own, or reinstalls itself via `setMetricSink`, would deadlock
122/// against the very mutex its caller holds. The sink is copied out (a
123/// reference-count bump, not a `std::function` copy) and the lock released
124/// before the call.
125/// - **Inside `catch (...)`.** A throwing sink must not escape into the
126/// framework. `LocalBackend::execute` brackets each dispatch with `beginSpan`
127/// / `emitMetric` / `endSpan` and settles the caller's `Completion` *after*
128/// them, precisely so a completion callback cannot observe the dispatch as
129/// finished before its metrics land. An exception thrown out of instrumentation
130/// would therefore skip `setValue`/`setException` entirely and be swallowed by
131/// the strand's catch-and-log (`LoggedTask`), leaving that `Completion` unsettled
132/// forever — a hung caller with neither a value nor an error, caused by a bug
133/// in a metrics callback.
134///
135/// A sink that throws is otherwise ignored: observability is not permitted to
136/// change program behavior, and reporting the failure through `morph::log` would
137/// invite the same re-entrancy this function exists to tolerate.
138///
139/// @param metric Which metric this observation is for.
140/// @param value The observed value.
141/// @param tags Dimensions for this observation; empty by default.
142inline void emitMetric(Metric metric, double value,
143 std::span<const std::pair<std::string_view, std::string_view>> tags = {}) {
144 auto& state = observeState();
145 if (!state.metricsOn.load(std::memory_order_relaxed)) {
146 return;
147 }
148 std::shared_ptr<const MetricSink> sink;
149 {
150 std::scoped_lock const lock{state.metricMtx};
151 sink = state.metricSink;
152 }
153 if (!sink || !*sink) {
154 return;
155 }
156 try {
157 (*sink)(MetricEvent{.metric = metric, .value = value, .tags = tags});
158 } catch (...) { // NOLINT(bugprone-empty-catch)
159 }
160}
161
162/// @brief Begins a span if a trace sink is installed; returns the `SpanId{0}`
163/// sentinel (no mutex, no sink call) if not.
164///
165/// @param requestId Caller's correlation id (`Context::requestId`); may be empty.
166/// @param modelType Target model type id.
167/// @param actionType Target action type id.
168/// @return A `SpanId` to pass to `endSpan`, or `0` if tracing is disabled.
169[[nodiscard]] inline SpanId beginSpan(std::string_view requestId, std::string_view modelType,
170 std::string_view actionType) {
171 auto& state = observeState();
172 if (!state.traceOn.load(std::memory_order_relaxed)) {
173 return SpanId{0};
174 }
175 std::shared_ptr<const TraceSink> sink;
176 {
177 std::scoped_lock const lock{state.traceMtx};
178 sink = state.traceSink;
179 }
180 if (!sink || !sink->beginSpan) {
181 return SpanId{0};
182 }
183 // Invoked outside the lock and inside catch(...) for the same two reasons
184 // documented on `emitMetric`. A sink that throws yields the "no span"
185 // sentinel, which every call site already handles.
186 try {
187 return sink->beginSpan(requestId, modelType, actionType);
188 } catch (...) {
189 return SpanId{0};
190 }
191}
192
193/// @brief Ends the span @p id. No-op if @p id is the sentinel `0` (tracing was
194/// disabled when the matching `beginSpan` ran) or no trace sink is installed.
195///
196/// @param id Span id returned by the matching `beginSpan` call.
197/// @param ok `true` if the dispatch resolved successfully, `false` on error.
198inline void endSpan(SpanId id, bool ok) {
199 if (id == 0) {
200 return;
201 }
202 auto& state = observeState();
203 std::shared_ptr<const TraceSink> sink;
204 {
205 std::scoped_lock const lock{state.traceMtx};
206 sink = state.traceSink;
207 }
208 if (!sink || !sink->endSpan) {
209 return;
210 }
211 // Outside the lock, inside catch(...) — see `emitMetric`.
212 try {
213 sink->endSpan(id, ok);
214 } catch (...) { // NOLINT(bugprone-empty-catch)
215 }
216}
217
218} // namespace detail
219
220// ── Configuration ─────────────────────────────────────────────────────────────
221
222/// @brief Installs the process-wide metric sink.
223///
224/// Thread-safe (mutex-guarded swap), exactly like `morph::log::setLogger`. Pass
225/// `nullptr` (or a default-constructed `MetricSink`) to disable metrics again.
226/// @param sink New sink, or an empty `MetricSink` to disable.
227inline void setMetricSink(MetricSink sink) {
228 auto& state = detail::observeState();
229 bool const enabled = static_cast<bool>(sink);
230 auto held = std::make_shared<const MetricSink>(std::move(sink));
231 std::scoped_lock const lock{state.metricMtx};
232 state.metricSink = std::move(held);
233 state.metricsOn.store(enabled, std::memory_order_relaxed);
234}
235
236/// @brief Lock-free "is a metric sink installed?" check for the hot path.
237/// @return `true` if a non-null sink is currently installed.
238[[nodiscard]] inline bool metricsEnabled() noexcept {
239 return detail::observeState().metricsOn.load(std::memory_order_relaxed);
240}
241
242/// @brief Installs the process-wide trace sink.
243///
244/// Thread-safe (mutex-guarded swap). Both `TraceSink::beginSpan` and
245/// `TraceSink::endSpan` must be set for tracing to take effect — a sink with
246/// only one of the two behaves as if no sink were installed (`detail::beginSpan`
247/// keeps returning the `0` sentinel). Pass a default-constructed `TraceSink{}`
248/// to disable tracing again.
249/// @param sink New trace sink, or `TraceSink{}` to disable.
250inline void setTraceSink(TraceSink sink) {
251 auto& state = detail::observeState();
252 bool const enabled = static_cast<bool>(sink.beginSpan) && static_cast<bool>(sink.endSpan);
253 auto held = std::make_shared<const TraceSink>(std::move(sink));
254 std::scoped_lock const lock{state.traceMtx};
255 state.traceSink = std::move(held);
256 state.traceOn.store(enabled, std::memory_order_relaxed);
257}
258
259// ── Scoped override (test fixture) ────────────────────────────────────────────
260
261/// @brief RAII helper that snapshots the global metric and trace sinks and
262/// restores them in the destructor.
263///
264/// Mirrors `morph::log::ScopedLoggerOverride`'s snapshot-only constructor:
265/// designed for tests that install their own sink(s) mid-test via
266/// `setMetricSink()` / `setTraceSink()` and want automatic restoration, so a
267/// custom sink never leaks into a later test case. Because both sinks are
268/// *global* state, tests using this guard must run serially — the same
269/// constraint `ScopedLoggerOverride` documents.
271public:
272 /// @brief Snapshots the current metric and trace sinks without changing them.
274 auto& state = detail::observeState();
275 {
276 std::scoped_lock const lock{state.metricMtx};
277 if (state.metricSink) {
278 _savedMetricSink = *state.metricSink;
279 }
280 }
281 {
282 std::scoped_lock const lock{state.traceMtx};
283 if (state.traceSink) {
284 _savedTraceSink = *state.traceSink;
285 }
286 }
287 }
288
289 /// @brief Restores the saved metric and trace sinks.
291 setMetricSink(std::move(_savedMetricSink));
292 setTraceSink(std::move(_savedTraceSink));
293 }
294
296 ScopedObserveOverride& operator=(const ScopedObserveOverride&) = delete;
298 ScopedObserveOverride& operator=(ScopedObserveOverride&&) = delete;
299
300private:
301 MetricSink _savedMetricSink;
302 TraceSink _savedTraceSink;
303};
304
305} // namespace morph::observe
RAII helper that snapshots the global metric and trace sinks and restores them in the destructor.
~ScopedObserveOverride()
Restores the saved metric and trace sinks.
ScopedObserveOverride()
Snapshots the current metric and trace sinks without changing them.
One metric observation delivered to the installed MetricSink.
double value
The observed value (a duration, a count, or a gauge level).
Metric metric
Which metric this observation is for.
std::span< const std::pair< std::string_view, std::string_view > > tags
Dimensions for this observation (e.g. modelType, actionType, outcome), without baking them into Metri...
Host-supplied hooks bracketing one dispatch, for distributed tracing.
std::function< SpanId(std::string_view requestId, std::string_view modelType, std::string_view actionType)> beginSpan
Called when an execute begins dispatch (on the model strand).
std::function< void(SpanId spanId, bool ok)> endSpan
Called when the dispatch resolves. ok is true for a successful (ok/resolved-value) outcome,...