Phase 69l — Telemetry seam zero-cost gate (migration)
Phase 69l — Telemetry seam zero-cost gate (migration)
What changes
Api.make's default IRemotingTelemetry bridge now pairs with a defaultBridgeGate that short-circuits the per-request Stopwatch + MethodTelemetry allocation when the registered IMetricsSink resolves to NoOpMetricsSink (the NoMetricsEndpoint default).
Before Phase 69l, every Api.make-composed API allocated a Stopwatch, a MethodTelemetry record, a tags Map, and did a trailing-segment Substring + DI service-locator lookup on every request — even when the resolved sink was NoOpMetricsSink and the work was unobserved. The terminating Record call was a no-op, but everything upstream paid in full. GP 13 ("advanced behaviour is opt-in; deployments that don't use it pay nothing") was technically violated.
Phase 69l adds:
RemotingOptions.TelemetryGate: (unit -> bool) option— a new optional field on theRemotingOptionsrecord. WhenSome, the dispatcher invokes the gate before allocating the per-request telemetry record.falseskips the allocation entirely.Remoting.withTelemetryGate gate options— the setter consumers can pair with their ownIRemotingTelemetryif they want gated emission.ApiSeams.defaultBridgeGate— the gate theApi.makewrapper composes against the default bridge. Process-memoised; resolvesIMetricsSinkfrom the AsyncLocal-stashedIServiceProvideronce, then caches the bool answer. Returnsfalsewhen the sink isNoOpMetricsSink;trueotherwise (consumer-installed companion sinks fall through totrue).- Both the non-streaming and SSE-streaming branches of
GiraffeAdapter.fsgate theStopwatch+MethodTelemetryallocation on the newtelemetryActivediscriminant.
Consumer-supplied ?telemetry sinks bypass the gate — the consumer opted in to telemetry, so the dispatcher emits unconditionally for them.
Diff to apply
No consumer source change required. The fix is internal to the dispatcher and the Api.make wrapper. Existing Api.make (api, errorHandler = eh, ...) call sites pick up the gate automatically at the next package upgrade.
Verification
dotnet build ToolUp.Forge.sln— clean.dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsproj— green (1,794 passed, 8 ignored, 0 failed; the newPhase 69l — Telemetry seam zero-cost gatepack adds 9 source-audit tests pinning the gate composition + dispatcher wiring).- The Phase 69l test pack lives in
src/ToolUp.Platform.Tests/InProcess/TelemetryZeroCostGateTests.fs. It textually pins (a)RemotingOptions.TelemetryGatefield declaration, (b)Remoting.withTelemetryGatesetter presence, (c)createApidefaultsTelemetryGate = None, (d)ApiSeams.defaultBridgeGateexists and detectsNoOpMetricsSink, (e)Api.makecomposeswithTelemetryGate ApiSeams.defaultBridgeGateconditionally ontelemetryIsDefault, (f) both dispatcher branches read the gate before allocation. - An integration-shape allocation-pin test (compose a tracking
IRemotingTelemetry, dispatch N requests through aTestServer, assert zeroOnMethodCompletedinvocations whenMetricsEndpoint = NoMetricsEndpoint) is the right next step but requires a TestServer scaffold theToolUp.Platform.Testsrunner does not have today. Tracked as a follow-up TIDY-UP item.
Rollback
Revert the gate composition in ToolUp.Platform.Server/Server/Api.fs (the |> if telemetryIsDefault then Remoting.withTelemetryGate ApiSeams.defaultBridgeGate else id line) AND the dispatcher gates in ToolUp.Platform.Server/Server/Remoting/Giraffe/GiraffeAdapter.fs (the telemetryActive / streamingTelemetryActive discriminants + the if … then Stopwatch.StartNew() else … guards). RemotingOptions.TelemetryGate may stay as an additive field; the dispatcher with TelemetryGate = None (the createApi default) emits exactly as pre-69l.
See also
69b-remoting-platform-seams.md— theToolUp.Remoting.Serverplatform seams substrate this gate composes on top of.docs/platform/portability-rules.md—IMetricsSinkis the singleton sink the default bridge reads.