Repository navigation
Phase 5c: tracing and metrics — the tracing and metrics layer - #66
Merged
Merged
Conversation
Wahbeh-Mohammad
changed the base branch from
18-phase-5a-configuration-docs
to
main
September 18, 2026 08:51
Phase 5c (issue #20), the first of four code units. A 5c-first execution: phase 5b is not running and scope.rb and tracing.rb read its two diagnostic-context key names, so this creates lib/dexpace/instrumentation/diagnostics.rb with exactly the three constants 5b's design fixes -- TRACE_ID = :"trace.id", SPAN_ID = :"span.id" and DEFAULT_KEYS, OBS-10's "exactly {trace.id, span.id}" -- and nothing else of 5b's; its Task 6 extends the file and creates no second one (P5-71). Symbols, not Strings: Fiber.current.storage hands keys back as Symbols, Fiber#storage= refuses a String key on every supported Ruby, and on 3.2 and 3.3 the per-key Fiber[]= refuses one too (measured 2026-09-17; the interning the corpus records arrives at 3.4). TraceIdFlavour#generate_trace_id(generator = ::SecureRandom) is OBS-27's generation over phase 4a's flavour Data, one branch per flavour on `case name` (P5-50): W3C draws #hex(16), 32 lowercase hex by construction; DATADOG draws #random_number(1 << 64) rendered decimal; NONE returns its sentinel. A zero draw is coerced to the lowest non-zero value -- a substitution, not a redraw, because a redraw against an injected always-zero generator never terminates and OBS-27's word is "coerced". The generator is a test seam answering SecureRandom's own two methods, which a seeded Random shares; the draw is XCUT-21's CSPRNG path, kept apart from CFG-32's (boundary 8). No span-id generator ships (P5-44). `require "securerandom"` is the one plain require 5c adds, already on the allowlist. Bundle#sampled? reads the low bit of trace_flags, the one method phase 4a reserved for phase 5, over the member it stored and without a ninth: Bundle.members is the same eight (boundary 10).
Phase 5c (issue #20), the second code unit: the protocols phase 4a postponed, given to the classes behind its three published singletons in place, so every object keeps the identity 4a gave it and the RBS interfaces are filled where 4a declared them empty on purpose. NoSpan gains OBS-21's seven methods -- #recording? false, the three mutators inert and returning self, #status= returning its argument with no reader, #finish nil on every call, #context Bundle::NONE -- and NoTracer gains OBS-25's #start_span and #in_span, both handing back NO_SPAN; NoTracerFactory is untouched (P4-8, boundary 10). Every method works on a frozen receiver and writes no ivar, which is what OBS-25's no-allocation clause wants; every attributes parameter is a named optional keyword and never a ** splat (P5-42). OBS-29's 1:1 clause is read as binding stateful tracers only (P5-43). Scope is OBS-22's handle: a plain three-ivar class holding the previous span and the two prior diagnostic values on the Ruby call stack, never a stack in fiber storage, because copy-on-write protects the slot and not the object in it (R13, P5-46); #close restores all three as one branchless assignment each (P5-49). NO_SCOPE is the cached singleton OBS-25 requires, returned on an identity test -- the span is already current -- and never on the recording flag, which would leave a recording span un-restored under a non-recording one (P5-47). The current-span slot key is a private_constant of scope.rb. Tracing is a function module over that one Fiber[] slot: .current_span never returns nil; .activate and .with_span are OBS-22; .correlate and .with_correlated_span are OBS-23, pushing 5b's two keys for a recording span with a valid bundle and delegating to plain activation otherwise -- the invalid-bundle guard keeps Bundle::NONE's all-zero ids out of every log line (OBS-26). Nothing here rescues: OBS-20 and OBS-30 make a throwing tracer the caller's, and the block forms restore through ensure regardless. On the 3.2 floor `Fiber[:k] = nil` retains the key with a nil value, and the floor offers no removal but the warned whole-map setter core never calls; the residual is unobservable at every reader and is P5-72.
Phase 5c (issue #20), the third code unit. HTTPTracer is OBS-28's event vocabulary richer than start/end: a module of eleven no-op methods in three groups -- the operation lifecycle, the per-attempt events and the transport milestones -- with the arities phases 6 and 8 emit against, so an implementer includes it and overrides only what it needs and the rest stay no-ops rather than NoMethodErrors in the caller's request path (OBS-30). NULL is the frozen instance design §8.1 names. OBS-29's ordering is a contract on an implementer's emitter, stated in the module's YARD and asserted by the tests PR's ordering suite over a conformant fake; nothing in phase 5 emits any of it, which OBS-29's own last sentence anticipates (R14) -- the per-attempt group is phase 6a's retry step, the milestones phase 8's, the operation triple phase 10's inbound list. CallableAdapter is §8.1's bus shape over one #call(name, payload) object, allocating a payload Hash per event by construction, which is why it is offered and not the default; it refuses a non-callable at construction and returns nil from every callback (P5-75). NO_METER is OBS-31's default: a frozen no-op meter whose #create_counter and #create_histogram return one shared frozen instrument each, whatever the name, with the meter and both instruments private classes behind the public constant -- sharing is assertable meter-to-meter under two names without naming either instrument, the asymmetry with NO_SPAN the design argues. The counter documents OBS-33's non-negative rule and validates nothing on the hot path; the histogram discards NaN and either infinity. The interfaces _Meter, _Counter and _Histogram are declared filled here and only here, for 5b's step to type against. No instrument name, unit or attribute set is fixed (OBS-32 is post-v1).
Phase 5c (issue #20), the fourth code unit: the three files a gate reads at run time, which travel with the code so the code tip keeps every gate but the coverage floor green. The runtime surface manifest of dexpace-core grows from 730 to 772 lines through `rake surface:regenerate`, once, and every one of the 42 added rows was read against the design's object model before it was accepted: Bundle#sampled?; CallableAdapter and its eleven; Diagnostics and its three constants; HTTPTracer and its eleven; NO_METER, NO_SCOPE and NULL, each typed by its private class; Scope with #close and .build; TraceIdFlavour#generate_trace_id; and Tracing with its five functions as `#` rows, the shape URL and PercentEncoding take under `extend self`. No private constant has a row -- the six no-op classes, the two instrument singletons and the current-span key are absent -- and NO_SPAN, NO_TRACER and NO_TRACER_FACTORY still appear only as the value type of their constant's row, so the methods added to their classes add no row. Nothing removed. The core smoke suite gains the tracing-and-metrics Layers case: the thirteen public constants under Dexpace::Instrumentation, 5b's three diagnostics constants among them (P5-71), and the privacy of the rest by qualified reference. Phase 4a's no_span_test.rb and no_tracer_test.rb pinned the empty surface phase 4 published ("responds to nothing beyond Object"), which the widened classes falsify; the two cases are re-pinned here to the exact protocol -- seven methods on NoSpan, two on NoTracer, one on the factory -- and the tests PR adds the behaviour suites beside them.
Wahbeh-Mohammad
force-pushed
the
20-phase-5c-tracing-and-metrics
branch
from
September 18, 2026 08:55
e1094a8 to
5b63ef0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #20. Fourth PR of the nine-PR phase-5 stack — phase 5c's code. Targets phase 5a's docs PR below it; its tests are the next PR up and the phase record the one after.
Base note. Phase 5c was built and reviewed in parallel with phase 5a, both cut from
mainat993c439, and its three branches were then rebased onto 5a's docs tipd5df0caby a reconciliation agent: nine collisions — the entry file's two phase-5 blocks, kept in sub-phase order; the smoke suite's two layer pins; the manifest, regenerated on the combined tree to 856 = 730 + 84 + 42 rows and byte-identical to git's auto-merge; and six prose files, both lanes' content kept with every count re-derived — each resolved inside the 5c commit that caused it, commit messages unchanged. Every 5c-only file is byte-identical to the reviewed tip093e825(git diff 093e825 e1094a8 -- <file>is empty for all of them), and the three rebased tips were re-proven in full (all seventeen gates, the matrix on all four Rubies, 5c's independence test and 5a's hermeticity checks on the combined tree). Because 5c'sscope.rbandtracing.rbrequire 5b'sdiagnostics.rbfor the two diagnostic-context key names, this was the 5c-first execution the 5c plan's Global Constraints anticipate: 5c created that file holding exactly the three constants 5b's design fixes —Diagnostics::TRACE_ID = :"trace.id",SPAN_ID = :"span.id",DEFAULT_KEYS— and nothing else, and 5b's Task 6 extends it three PRs up (P5-71).What lands
The tracing and metrics half of chapter 15 in
dexpace-core— 25 files, +1,131 / −30: the span and scope protocols behind phase 4a's three published singletons, the current-span carrier and the log-correlation scope,OBS-27's trace-id generation, the HTTP-shaped tracer vocabulary with its ordering contract, and the metrics SPI with its no-op meter.OBS-21–OBS-23,OBS-25–OBS-33. The only plainrequireadded issecurerandom(allowlisted); the gemspec keeps zeroadd_dependencylines; norescueexists in any 5c lib file (OBS-30's asymmetry — tracer and meter calls are never wrapped); no method in the segment takes a**splat (OBS-25; phase 0'sDexpace/NoKeywordSplatcop, verified live).NO_SPAN's private class gains exactly the seven protocol methods (#recording?false,#set_attribute/#add_event/#record_errorreturningself,#status=,#finishidempotent,#context→Bundle::NONE), every one working on the frozen receiver phase 4a published, so the object keeps its identity;NO_TRACERgains#start_span/#in_spanhanding backNO_SPAN;NoTracerFactory#tracerkeeps its five-argument shape verbatim (P4-8)._Spanand_Tracerare widened in place in 4a'ssig/files.Bundle.membersis the same eight before and after,#valid?stays derived,Bundlegains#sampled?only (the low bit oftrace_flags),TraceIdFlavourstays aDatawith.of,NONE's sentinel unchanged — no second no-op span, tracer, factory or meter anywhere inlib/, pinned by the smoke suite's constant list. Phase 4a'sno_span_test.rb/no_tracer_test.rb"responds to nothing" pins are re-pinned to the exact protocol here (a necessary consequence of widening; the two suites must be green at this tip).TraceIdFlavour#generate_trace_id(generator = ::SecureRandom)—W3Cdraws#hex(16),DATADOG#random_number(1 << 64)rendered decimal,NONEreturns the invalid sentinel by identity; a zero draw is coerced ("0"*31 + "1",1) through the injected generator seam because the branch is unreachable by sampling.XCUT-21's CSPRNG path, deliberately a separate code path fromCFG-32's PRNG (charter boundary 8). No span-id generator ships (P5-44).Dexpace::Instrumentation::ScopeandNO_SCOPE— a plain three-ivar class holding the previous span on the Ruby call stack, never a stack in fiber storage (R13, P5-46);NO_SCOPEis returned on an identity test (the span being activated is already current) and not on the recording flag (P5-47).Scope.buildis public surface marked@api private(P5-74).Dexpace::Instrumentation::Tracing(extend self) —.current_span,.activate,.with_span,.correlate,.with_correlated_span.correlatepushes the bundle's ids underDiagnostics::TRACE_ID/SPAN_IDintoFiber[](neverThread.current[]orContextStore— charter boundary 14; a child thread, fiber and enumerator all see the pushed keys), skips the push for a non-recording span or an invalid bundle and delegates to plain activation, and restores each key to its prior value as one branchless assignment in the sameensurethat restores the span (R12, P5-49). The floor's residual is recorded, not worked around (P5-72):Fiber[:k] = nildeletes the key only from Ruby 3.3 — on 3.2.11 it retains a nil-valued key, and 3.2 offers no removal but the warnedFiber#storage=, which core never calls — soOBS-23's "remove it if previously unset" is a removal on 3.3+ and a present-and-nil key on the floor, unobservable atFiber[]and atOBS-10's null-skipping fold; the suite asserts what each row can honour and pins the 3.3.0 boundary.Dexpace::Instrumentation::HTTPTracerand::NULL— a module of exactly eleven no-op methods inOBS-28's three groups with the arities phases 6 and 8 emit against pinned;NULLthe frozen instance of a private class including it.CallableAdapter— §8.1's bus shape, forwarding each callback as its name plus a payload keyed by parameter name to one#call(name, payload)object; refuses a non-callable at construction (P5-75).OBS-29's ordering contract ships as the vocabulary plus an ordering test over a conformant emitter fake; nothing in phase 5 emits it — the per-attempt half waits on phase 6a's retry step (its Task 9), the rest on phase 10's inbound list (R14).NO_METER—#create_counter/#create_histogramreturn one shared frozen instrument each;#add/#recordreturnnilwith no hot-path validation (OBS-33); the meter and both instruments areprivate_constantclasses behind the public constant;_Meter/_Counter/_Histogramdeclared filled for 5b's step to type against.sig/mirrorslib/one file per file (110 == 110);sig/dexpace.rbsunchanged. The surface manifest regenerated once (730 → 772, all 42 rows read against the object model; the private no-op classes appear only as the value type of their constants' rows). The core smoke suite's constant list is on this branch because the code change invalidates it.Decisions taken in the open, against the plan's text
Ledger rows P5-71–P5-76 and the checklist's "Deviations from the plan" itemise them:
diagnostics.rbshipped early (P5-71); the floor residual (P5-72); the four recording doubles namespacedDexpace::Recording*against the tree's top-level convention, because 5b's plan consumes three of them by exactly those names (P5-73);Scope.buildpublic (P5-74);CallableAdapterrefusing a non-callable (P5-75);NO_TRACER#in_spanwithout a block returningNO_SPAN, its RBS declaring the block optional on the private class while_Tracerkeeps it required (P5-76).Tracingisextend self(Style/ModuleFunction), and its RBS types the span as_Span & Objectso Steep accepts the identity comparison.Layering
Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 97.86% on 4.0.6 at the reviewed tip and 98.14% at this rebased tip (1,763 runs, 0 failures; the matrix set green on 3.2.11). The tests PR takes the same tree to 99.97%.
Verification
Bundle.membersthe same eight;Bundle::NONE.span.equal?(NO_SPAN)and.tracer_factory.equal?(NO_TRACER_FACTORY); every#tracerargument shape returnsNO_TRACER; sentinels"0"*32/"0"*16,valid?false; all singletons frozen; one no-op class of each kind.#recording?true; a mutator returningnil;#finishraising twice; a**attributessplat (caught by the cop and the allocation test); one allocation on the no-op path; the factory minting a fresh tracer; a freshScopefor the current span; no restore on raise;Thread.current[]as carrier; a prior value not restored; an unset key left present; a push for a non-recording span; arescueinsideTracing;#sampled?reading bit 2; W3C uppercase / all-zero, DATADOG"0"/ above 2⁶⁴−1,NONEnot the sentinel; a non-nilHTTPTracerdefault; a wrong adapter name; started-after-succeeded and exhausted-without-failed orderings; a counter#addallocating.OBS-25as a two-loopGC.statdelta of exactly 0.0 per call over 22 no-op call shapes; nestedRecordingSpanactivation restoring after a raise;correlate's push, restore and removal (absent on 4.0.6, present-and-nil on 3.2.11, as recorded); 10,000 W3C and 10,000 DATADOG draws all valid and unique, 8 threads × 1,000 concurrent; a raising tracer and a raising meter propagating unwrapped; Task 11's independence subprocess loading the ten 5c files withEvent,Logger,ConfigurationandClockundefined.Known follow-ups from the review (not blocking)
tracing_matrix_facts_test.rbcallsFiber#storage=in one fact-pin test, deliberately, wrapped inWarningCapturewith the prior map restored; no call anywhere inlib/. Acceptable as a fact pin; could move into a subprocess if the letter of "never in test" is wanted.ASYNC-9/ASYNC-11restore are written on the 3.4.10-only "= nildeletes" fact; routed as a newdocs/knowledge/notes/observability.mdentry and the roadmap's thirty-ninth inbound bullet. On 3.2 a per-key restore returns a worker to a map of nil-valued keys, not an empty map.Scope.buildisNFR-4-locked public surface at the first tag (P5-74) — flagged for the human consolidating the ledger.diagnosticsline fromlib/dexpace.rbyields aLoadErrordid not hold:scope.rbandtracing.rbrequire_relativeit themselves, so the entry-file line is the explicit-requires convention rather than load-bearing.Dexpace/NoKeywordSplat, scoped togems/*/lib); the tracer-factory name collision stays on phase 10's inbound list.