You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
observability: Tracy zones behind MORPH_ENABLE_TRACY (prefixed MORPH_ZONE macros) and a capture job that proves they fire #843
This ticket is Part 1, the compile-time Tracy zones, together with the capture job that proves those zones fire. Part 2 (the Tracy sink for morph::observe and the statistics collector) is blocked on two spec decisions and has moved to #860. Part 3's further benchmarks are optional follow-ups.
To implement:
MORPH_ENABLE_TRACY (default OFF). First try find_package(Tracy CONFIG QUIET), then fall back to CPM wolfpld/tracy at v0.13.1, the tag Lightweight pins, so that a process ends up with exactly one TracyClient. The define and the Tracy::TracyClient link are INTERFACE.
include/morph/core/profiler.hpp with prefixed macros: MORPH_ZONE, MORPH_ZONE_TEXT, MORPH_PLOT, MORPH_THREAD_NAME, MORPH_MESSAGE. They forward to Tracy when it is on and are argument-consuming stubs when it is off. Never define Tracy's bare names; the reason is below.
Zones on the hot paths listed below, one zone per phase per thread. Link the phases by writing Context::requestId as zone text. Name the threads: pool workers, the I/O loop, and the TimeoutScheduler.
Document the ODR rule: every translation unit must agree on MORPH_TRACY_ENABLED.
A nightly tracy-capture job that runs morph_bench under capture and asserts named zones have non-zero counts. It must be shown to fail when a zone is removed.
Premise re-checked on master @ 85a5280e: no Tracy instrumentation in include/morph or src.
Done when: a Tracy-ON build shows the named zones in a capture, a Tracy-OFF build compiles with no warnings and no Tracy dependency, and the capture job goes red with a zone removed.
Summary
morph has no profiler instrumentation. It has a runtime metrics/trace seam (morph::observe) and two opt-in benchmarks, but nothing connects them to a profiler, and nothing records benchmark results over time. Lightweight, which morph already uses as a dependency, has a design worth copying: compile-time Tracy macros that become no-ops when disabled, plus a runtime statistics collector that also sends its samples to Tracy when both are on. This issue proposes the same for morph, in three parts that can land separately.
What exists today
Verification status: everything below was read from the code on master @ 9d001052, from Lightweight's cached source (.cache/cpm/lightweight/650f0137…), and from Lightweight's upstream docs/statistics.md and .github/workflows/build.yml. Nothing was built or profiled for this issue.
Tracy: none.
$ grep -rniI "tracy\|ZoneScoped\|FrameMark" --exclude-dir=build --exclude-dir=.cache --exclude-dir=.git .
docs/superpowers/specs/2026-09-14-runner-fleet-org-wide-design.md:443:- fastcached `package-linux` and Lightweight `tracy_capture` — excluded at
A process-wide MetricSink and TraceSink (beginSpan/endSpan).
When no sink is installed, each call site costs one relaxed atomic load.
It is fed from RemoteServer/LocalBackend dispatch, register and deregister, SyncWorker, ReconnectCoordinator, and the offline queues' overflow.
The spec says outright: "No sampling/aggregation/histograms in morph" and "there is no bundled backend."
Benchmarks: two targets, opt-in (MORPH_BUILD_LOAD_TESTS=ON, tests/bench/).
morph_bench measures RemoteServer dispatch latency and throughput against an echo model. It takes best, median and worst over N trials, gates on the best trial, and writes bench_dispatch_latency.json / .jsonl.
morph_bench_alloc counts allocations per local execute round trip. It is gated in ctest as bench.alloc_budget.
No workflow archives the JSON, although the benchmark's own header says it is written "so CI can archive successive runs and diff them for regressions":
$ grep -n "bench\|jsonl" .github/workflows/*.yml
.github/workflows/ci.yml:2282: # harnesses, the vetted-HMAC adapters, the soak/bench targets) were never
Not verified: which CI jobs actually run the bench ctest label, as opposed to only building the targets.
Nothing measures the codec (ActionTraits<A>::toJson/fromJson), the wire envelope, a SocketBackend loopback round trip, Completion chaining, strand fan-out across many models, journal append, or schema generation.
How Lightweight does it
LIGHTWEIGHT_ENABLE_TRACY (default OFF). It tries find_package(Tracy CONFIG) first, then falls back to CPM wolfpld/tracyv0.13.1 with TRACY_ENABLE and TRACY_ON_DEMAND. The library target gets PUBLIC LIGHTWEIGHT_TRACY_ENABLED and PUBLIC Tracy::TracyClient. There is also a vcpkg feature, lightweight[tracy].
TracyProfiler.hpp includes <tracy/Tracy.hpp> when Tracy is enabled. Otherwise it defines no-op stubs for Tracy's own macro names (ZoneScoped, ZoneScopedN, ZoneText, TracyPlot, TracyMessage, TracySetThreadName, …), each written to use up its arguments so disabled builds get no unused-variable warnings. There are 99 call sites in src/, for example ZoneScopedN("SqlTransaction::TryCommit").
SqlStatistics is always compiled in and switched on at runtime with Enable()/Disable(). It uses relaxed atomics and records power-of-two latency histograms. Snapshot() returns a plain value type, which can be diffed, exported, or asserted on in a test. When both Tracy and statistics are on, every sample is also sent as a TracyPlot. Statistics never depends on Tracy, in either direction.
CI job tracy_capture. It builds tracy-capture and tracy-csvexport at the pinned tag and builds an example with Tracy on. It starts the capture, runs the example (which calls SqlStatistics::Enable()), then checks that the counters are non-zero and that at least one latency sample is non-zero, so the job fails if the instrumentation records nothing.
Proposal: three parts, each landable alone
Part 1: compile-time Tracy zones, behind MORPH_ENABLE_TRACY (default OFF)
CMake:find_package(Tracy CONFIG QUIET), then CPM fallback. Use the same shape as core-cpp is getting in build: find core-cpp with find_package before CPMAddPackage, so packaged builds need no fetch #840, and the same tag Lightweight pins (v0.13.1). A process that links both libraries must end up with exactly one TracyClient, and two client copies of different versions in one process do not work. morph is an INTERFACE target, so the define and the Tracy::TracyClient link are INTERFACE too.
Use prefixed macros, not Tracy's bare names. Lightweight defines stubs named ZoneScoped and so on. The examples include Lightweight and morph in the same translation units. If Lightweight had Tracy ON and morph OFF, morph's stubs would redefine Tracy's real macros. Proposed names: MORPH_ZONE("Bridge::executeVia"), MORPH_ZONE_TEXT(sv), MORPH_PLOT(name, v), MORPH_THREAD_NAME(name), MORPH_MESSAGE(sv), in include/morph/core/profiler.hpp. They forward to Tracy when it is enabled and are stubs otherwise.
Header-only ODR hazard, stated plainly: these macros change inline function bodies. Every translation unit in a program must agree on MORPH_TRACY_ENABLED. An INTERFACE definition gives that by construction for CMake consumers. Document it for anyone else.
Zones on the hot paths:Bridge::executeVia, BridgeSink settle, ActionDispatcher's runner (registry.hpp), the ModelStrands pump, RemoteServer::dispatchMessage/dispatchExecute, wire envelope encode/decode, ActionTraitstoJson/fromJson at the codec boundary, the SocketBackend I/O thread's read and send, journal success/failure recording, and offline enqueue/replay.
Thread names for pool workers, the socket I/O thread and the TimeoutScheduler thread.
A dispatch crosses threads (caller → strand → callback executor), and a Tracy zone must begin and end on the same thread. So each phase is its own zone on its own thread, and the phases are linked by Context::requestId written as zone text. Do not try to build one zone spanning the whole dispatch.
core-cpp's event loop and strands are core-cpp's to instrument. If they need it, that is a separate issue in core-cpp.
Part 2: an optional Tracy sink for morph::observe, plus an opt-in statistics collector
morph::observe is already the runtime-toggled half of Lightweight's design. What it lacks is a Tracy connection. Add a helper, only when MORPH_ENABLE_TRACY is on, that installs a MetricSink sending each Metric to a fixed-name TracyPlot (morph.executeLatencyMs, morph.executeInFlight, …).
The TraceSink maps to TracyMessage, not to zones, because beginSpan and endSpan can run on different threads.
Design question:morph::observe holds one sink per kind, so installing a Tracy sink would replace a host's Prometheus sink. That needs either a tee/fan-out helper or a documented "profiling builds only" rule.
Spec question (invariant 5): a Lightweight-style Snapshot() with latency histograms runs against the spec's "No sampling/aggregation/histograms in morph". It can be written as an optional sink implementation the host installs, so the emit path stays raw as the spec requires, and the benchmarks in Part 3 could read phase breakdowns from it. The spec owner should decide whether that sink ships in include/morph or stays in tests/bench.
Part 3: benchmarks that use both, and a CI capture job
Add benchmarks for the uncovered paths listed above. Reuse morph_bench's method (best-of-N gating, distribution reporting), which is documented in its header together with the reasons for it. Catch2 is already a dependency, so its BENCHMARK is available if a micro-benchmark harness is wanted. That choice belongs in the PR, not in this issue.
Upload bench_*.json / .jsonl as workflow artifacts on the job that runs them, so results can be compared across runs.
Add a tracy-capture job modelled on Lightweight's. Build with MORPH_ENABLE_TRACY=ON, run morph_bench under tracy-capture, export with tracy-csvexport, and assert on specific zone names with non-zero counts (e.g. Bridge::executeVia, ActionDispatcher::dispatch). Also mutate the build so a zone is removed, and confirm the job fails, since a capture that "succeeds" with no zones would prove nothing (AGENTS.md, Verify rather than assert). Put it on the nightly workflow, not on pull_request, in line with the move of slow legs to nightly.
Out of scope
Compile-time benchmarking (-ftime-trace over template instantiation of Completion<T>, BridgeHandler<Model> and ActionTraits<A>). That is where morph's real cost lies, but it is a different measurement with different tools and deserves its own issue.
Instrumenting core-cpp.
What would change this
Drop Part 1 if there is a reason Tracy cannot be an optional dependency of a header-only library. A consumer mixing Tracy-on and Tracy-off translation units in one binary is the case to check.
Drop Part 2's collector if the spec owner reaffirms "no aggregation in morph". The Tracy sink alone still stands.
Split this issue at triage into its three parts if they will not land in one lane. Each part is written to land without the others.
Triage: rescope — split into its three parts before anyone builds it
The body already anticipates this ("Split this issue at triage into its three parts if they will not land in one lane"). They will not: Part 1 is CMake + a new header + zones across core/net/journal/offline; Part 2 has two open decisions that belong to the spec owner; Part 3 is CI. Premise checked on master @ 85a5280e: grep -rniI "tracy\|ZoneScoped" include src finds nothing.
Part 1 (MORPH_ENABLE_TRACY, prefixed MORPH_ZONE macros) — well-framed; the prefixed-name and one-TracyClient constraints are the right ones. Landable alone.
Part 2 — blocked on decisions, not implementation: (a) single-sink morph::observe means a Tracy sink would evict a host's sink — tee helper or "profiling builds only"; (b) a histogram collector in include/morph contradicts observability.md's "No sampling/aggregation/histograms in morph" (invariant 5 — present both readings, do not pick). Not dispatchable until those are answered.
Part 3 — mostly CI/bench scaffolding. Under AGENTS.md's bar, CI configuration is fixed in the change that needs it, not tracked; the benchmarks themselves ride with Part 1.
Proposed rewrite: this issue keeps Part 1 (+ the Part 3 capture job that proves its zones fire); Part 2 is filed separately as a decision issue.
changed the title [-]observability: Tracy instrumentation behind MORPH_ENABLE_TRACY, a Tracy sink for morph::observe, and benchmarks that use both[/-][+]observability: Tracy zones behind MORPH_ENABLE_TRACY (prefixed MORPH_ZONE macros) and a capture job that proves they fire[/+]on Oct 3, 2026
Re-triaged to valid after narrowing the scope to Part 1, as the body now states. Part 2's two open decisions (the single-sink eviction, and the observability spec's no-aggregation rule) are tracked in #860.
A finding from implementing this (PR #861) that the issue body does not account for: the one-TracyClient constraint involves three libraries, not two.
core-cpp v0.5.0 (morph's own dependency) has its own Tracy instrumentation behind CORE_CPP_WITH_TRACY. It pins Tracy v0.14.1 under the same CPM name tracy (cmake/CoreCppDependencies.cmake). Lightweight pins v0.13.1. So "use the tag Lightweight pins" and "use the tag core-cpp pins" conflict as pinned upstream.
What PR #861 does about it (measured locally, macOS/clang 22):
Under morph, core-cpp fetches nothing (CORE_CPP_FETCH_DEPS OFF) and takes whatever Tracy::TracyClient its parent has already provided. Before the PR's ordering change, -DCORE_CPP_WITH_TRACY=ON failed configure with: "Tracy is needed because CORE_CPP_WITH_TRACY holds, but neither the parent project nor find_package(Tracy 0.14.1) provides Tracy::TracyClient".
The PR adds morph's Tracy before core-cpp. With MORPH_ENABLE_TRACY=ON CORE_CPP_WITH_TRACY=ON MORPH_INSTALL=OFF, configure succeeds and core-cpp-base/core-cpp-net build against the single v0.13.1 client.
With MORPH_INSTALL=ON, core-cpp's install rules still refuse to export targets that link a Tracy it did not install. That is core-cpp's install policy and is documented in docs/spec/core/profiler.md; it is not fixed here.
Not verified: whether core-cpp's CORE_* instrumentation is behaviourally correct against 0.13.1 rather than 0.14.1. It compiles, but nothing was captured with it.
What would change this: if Lightweight moves to 0.14.x, morph's MORPH_TRACY_GIT_TAG should move with it. One tag across all three libraries would then close the gap.
Scope, rewritten at triage: Part 1 only
This ticket is Part 1, the compile-time Tracy zones, together with the capture job that proves those zones fire. Part 2 (the Tracy sink for
morph::observeand the statistics collector) is blocked on two spec decisions and has moved to #860. Part 3's further benchmarks are optional follow-ups.To implement:
MORPH_ENABLE_TRACY(default OFF). First tryfind_package(Tracy CONFIG QUIET), then fall back to CPMwolfpld/tracyatv0.13.1, the tag Lightweight pins, so that a process ends up with exactly oneTracyClient. The define and theTracy::TracyClientlink areINTERFACE.include/morph/core/profiler.hppwith prefixed macros:MORPH_ZONE,MORPH_ZONE_TEXT,MORPH_PLOT,MORPH_THREAD_NAME,MORPH_MESSAGE. They forward to Tracy when it is on and are argument-consuming stubs when it is off. Never define Tracy's bare names; the reason is below.Context::requestIdas zone text. Name the threads: pool workers, the I/O loop, and theTimeoutScheduler.MORPH_TRACY_ENABLED.tracy-capturejob that runsmorph_benchunder capture and asserts named zones have non-zero counts. It must be shown to fail when a zone is removed.Premise re-checked on
master@85a5280e: no Tracy instrumentation ininclude/morphorsrc.Done when: a Tracy-ON build shows the named zones in a capture, a Tracy-OFF build compiles with no warnings and no Tracy dependency, and the capture job goes red with a zone removed.
Summary
morph has no profiler instrumentation. It has a runtime metrics/trace seam (
morph::observe) and two opt-in benchmarks, but nothing connects them to a profiler, and nothing records benchmark results over time. Lightweight, which morph already uses as a dependency, has a design worth copying: compile-time Tracy macros that become no-ops when disabled, plus a runtime statistics collector that also sends its samples to Tracy when both are on. This issue proposes the same for morph, in three parts that can land separately.What exists today
Verification status: everything below was read from the code on
master@9d001052, from Lightweight's cached source (.cache/cpm/lightweight/650f0137…), and from Lightweight's upstreamdocs/statistics.mdand.github/workflows/build.yml. Nothing was built or profiled for this issue.Tracy: none.
Runtime metrics/tracing:
morph::observe(include/morph/core/observability.hpp,docs/spec/core/observability.md).MetricSinkandTraceSink(beginSpan/endSpan).RemoteServer/LocalBackenddispatch, register and deregister,SyncWorker,ReconnectCoordinator, and the offline queues' overflow.morph" and "there is no bundled backend."Benchmarks: two targets, opt-in (
MORPH_BUILD_LOAD_TESTS=ON,tests/bench/).morph_benchmeasuresRemoteServerdispatch latency and throughput against an echo model. It takes best, median and worst over N trials, gates on the best trial, and writesbench_dispatch_latency.json/.jsonl.morph_bench_alloccounts allocations per localexecuteround trip. It is gated in ctest asbench.alloc_budget.benchctest label, as opposed to only building the targets.ActionTraits<A>::toJson/fromJson), the wire envelope, aSocketBackendloopback round trip,Completionchaining, strand fan-out across many models, journal append, or schema generation.How Lightweight does it
LIGHTWEIGHT_ENABLE_TRACY(default OFF). It triesfind_package(Tracy CONFIG)first, then falls back to CPMwolfpld/tracyv0.13.1withTRACY_ENABLEandTRACY_ON_DEMAND. The library target getsPUBLIC LIGHTWEIGHT_TRACY_ENABLEDandPUBLIC Tracy::TracyClient. There is also a vcpkg feature,lightweight[tracy].TracyProfiler.hppincludes<tracy/Tracy.hpp>when Tracy is enabled. Otherwise it defines no-op stubs for Tracy's own macro names (ZoneScoped,ZoneScopedN,ZoneText,TracyPlot,TracyMessage,TracySetThreadName, …), each written to use up its arguments so disabled builds get no unused-variable warnings. There are 99 call sites insrc/, for exampleZoneScopedN("SqlTransaction::TryCommit").SqlStatisticsis always compiled in and switched on at runtime withEnable()/Disable(). It uses relaxed atomics and records power-of-two latency histograms.Snapshot()returns a plain value type, which can be diffed, exported, or asserted on in a test. When both Tracy and statistics are on, every sample is also sent as aTracyPlot. Statistics never depends on Tracy, in either direction.tracy_capture. It buildstracy-captureandtracy-csvexportat the pinned tag and builds an example with Tracy on. It starts the capture, runs the example (which callsSqlStatistics::Enable()), then checks that the counters are non-zero and that at least one latency sample is non-zero, so the job fails if the instrumentation records nothing.Proposal: three parts, each landable alone
Part 1: compile-time Tracy zones, behind
MORPH_ENABLE_TRACY(default OFF)find_package(Tracy CONFIG QUIET), then CPM fallback. Use the same shape as core-cpp is getting in build: find core-cpp with find_package before CPMAddPackage, so packaged builds need no fetch #840, and the same tag Lightweight pins (v0.13.1). A process that links both libraries must end up with exactly oneTracyClient, and two client copies of different versions in one process do not work.morphis anINTERFACEtarget, so the define and theTracy::TracyClientlink areINTERFACEtoo.ZoneScopedand so on. The examples include Lightweight and morph in the same translation units. If Lightweight had Tracy ON and morph OFF, morph's stubs would redefine Tracy's real macros. Proposed names:MORPH_ZONE("Bridge::executeVia"),MORPH_ZONE_TEXT(sv),MORPH_PLOT(name, v),MORPH_THREAD_NAME(name),MORPH_MESSAGE(sv), ininclude/morph/core/profiler.hpp. They forward to Tracy when it is enabled and are stubs otherwise.MORPH_TRACY_ENABLED. AnINTERFACEdefinition gives that by construction for CMake consumers. Document it for anyone else.Bridge::executeVia,BridgeSinksettle,ActionDispatcher's runner (registry.hpp), theModelStrandspump,RemoteServer::dispatchMessage/dispatchExecute, wire envelope encode/decode,ActionTraitstoJson/fromJsonat the codec boundary, theSocketBackendI/O thread's read and send,journalsuccess/failure recording, and offline enqueue/replay.TimeoutSchedulerthread.Context::requestIdwritten as zone text. Do not try to build one zone spanning the whole dispatch.Part 2: an optional Tracy sink for
morph::observe, plus an opt-in statistics collectormorph::observeis already the runtime-toggled half of Lightweight's design. What it lacks is a Tracy connection. Add a helper, only whenMORPH_ENABLE_TRACYis on, that installs aMetricSinksending eachMetricto a fixed-nameTracyPlot(morph.executeLatencyMs,morph.executeInFlight, …).TraceSinkmaps toTracyMessage, not to zones, becausebeginSpanandendSpancan run on different threads.morph::observeholds one sink per kind, so installing a Tracy sink would replace a host's Prometheus sink. That needs either a tee/fan-out helper or a documented "profiling builds only" rule.Snapshot()with latency histograms runs against the spec's "No sampling/aggregation/histograms inmorph". It can be written as an optional sink implementation the host installs, so the emit path stays raw as the spec requires, and the benchmarks in Part 3 could read phase breakdowns from it. The spec owner should decide whether that sink ships ininclude/morphor stays intests/bench.Part 3: benchmarks that use both, and a CI capture job
morph_bench's method (best-of-N gating, distribution reporting), which is documented in its header together with the reasons for it. Catch2 is already a dependency, so itsBENCHMARKis available if a micro-benchmark harness is wanted. That choice belongs in the PR, not in this issue.bench_*.json/.jsonlas workflow artifacts on the job that runs them, so results can be compared across runs.tracy-capturejob modelled on Lightweight's. Build withMORPH_ENABLE_TRACY=ON, runmorph_benchundertracy-capture, export withtracy-csvexport, and assert on specific zone names with non-zero counts (e.g.Bridge::executeVia,ActionDispatcher::dispatch). Also mutate the build so a zone is removed, and confirm the job fails, since a capture that "succeeds" with no zones would prove nothing (AGENTS.md, Verify rather than assert). Put it on the nightly workflow, not onpull_request, in line with the move of slow legs to nightly.Out of scope
-ftime-traceover template instantiation ofCompletion<T>,BridgeHandler<Model>andActionTraits<A>). That is where morph's real cost lies, but it is a different measurement with different tools and deserves its own issue.What would change this