Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScriptObservability
This page lists each observability signal, the Axial package that produces it, what you get automatically and what you opt into, and the one-time wiring that sends it to a backend such as OpenTelemetry. The fuller guides are linked from each section.
| Signal | Where it comes from | Consumed by |
|---|---|---|
| Traces (spans) | Axial.Telemetry emitting on the Axial ActivitySource; Axial.Telemetry.JavaScript on Fable targets |
any ActivityListener, in practice the OpenTelemetry SDK; OpenTelemetry JS under Fable |
| Logs | the explicit ILog service, bridged to Microsoft.Extensions.Logging by Axial.Hosting |
your host's logging pipeline |
| Metrics | Axial.Telemetry: FiberMetrics and QueueMetrics on the Axial Meter |
OpenTelemetry's .AddMeter("Axial"), dotnet-counters, the Aspire dashboard |
| Fiber dumps | core Axial: FiberRegistry live-fiber snapshots, no telemetry dependency |
registry.DumpAt(Clock.live) on demand; FiberDumpTelemetry.record to put dumps on traces |
Two general-purpose channels feed those signals and are part of core Axial, not the telemetry
package: runtime annotations (Flow.annotate, ambient key–value diagnostics metadata) and fiber
observers (FiberObserver, lifecycle hooks for every forked fiber; see
Supervision and fiber observability).
How .NET tracing works: ActivitySource and ActivityListener
.NET has a built-in publish/subscribe tracing model in System.Diagnostics, and Axial sits entirely on the
publishing side:
- An
ActivitySourceis the producer. Axial owns one, named"Axial". Instrumented code callsStartActivity, and anActivityis a span: name, timing, tags, status, parent. - An
ActivityListeneris the consumer. Nothing is recorded until the application registers a listener that opts into a source by name and makes the sampling decision. With no interested listener,StartActivityreturnsnulland Axial skips all tagging work, so an untraced app pays roughly a null check per span site. Activity.Currentis an async-local holding the ambient span. New spans parent to it automatically, which is how Axial spans nest inside ASP.NET Core request spans (and under an upstreamtraceparentheader) with no wiring.
This split is why telemetry is runtime instrumentation rather than an environment service: the host decides once, at the edge, whether anything listens and where spans go; workflows never carry a tracing dependency.
What produces spans, and when
Tracing is explicit at workflow granularity. Axial does not span every flow { } or operator; a span
exists where you put one:
Shared setup
// Setup for the checked examples on this page.
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScript
/// Fails the docs test when an example's result differs from the value shown.
let shouldEqual expected actual =
if actual <> expected then failwithf "Expected %A but got %A" expected actual
SystemIOThreadingTasksAxialLayersConsoleFileSystemHostingBrowserNodePlatformServiceStateTelemetryJavaScriptshouldEqual: 'a -> 'a -> unitexpected: 'aactual: 'a(<>): 'T -> 'T -> boolStructural inequality The first parameter. The second parameter. The result of the comparison. 5 <> 5 // Evaluates to false 5 <> 6 // Evaluates to true [1; 2] <> [1; 2] // Evaluates to false
failwithf: Printf.StringFormat<'T,'Result> -> 'TPrint to a string buffer and raise an exception with the given result. Helper printers must return strings. The formatter. The formatted result. See Printf.failwithf (link: ) for examples.
open System.Diagnostics
open Axial.Telemetry
let applicationActivitySource = new ActivitySource("Orders.Application")
let placeOrder (order: int) : Flow<unit, string, int> =
flow { return order (* validate, charge, persist *) }
|> Activity.traceOn applicationActivitySource "orders.place"
SystemDiagnosticsAxialTelemetryapplicationActivitySource: ActivitySourceSystem.Diagnostics.ActivitySourceProvides APIs to create and start objects and to register objects to listen to the events.
placeOrder: int -> Flow<unit,string,int>order: intintAn abbreviation for the CLI type . Basic Types
Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
stringAn abbreviation for the CLI type . Basic Types
flow: FlowBuilderThe universal flow { } computation expression.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Telemetry.ActivitytraceOn: ActivitySource -> string -> Flow<'env,'error,'value> -> Flow<'env,'error,'value>Wraps a flow in an application-owned activity source, rendering typed errors without reflection. Errors are rendered with their own ToString, or by type name when that ToString needs reflection NativeAOT removed. Use traceWith to supply a renderer. The application-owned activity source that emits the span. The name of the activity. The flow to trace. A flow that executes within the activity span.
Any ActivityListener sees the span; the OpenTelemetry SDK is one. This one records the names of finished spans:
open System.Diagnostics
let finished = ResizeArray<string>()
let listener =
new ActivityListener(
ShouldListenTo = (fun source -> source.Name = "Orders.Application"),
Sample = SampleActivity<ActivityContext>(fun _ -> ActivitySamplingResult.AllData),
ActivityStopped = (fun activity -> finished.Add activity.DisplayName))
ActivitySource.AddActivityListener listener
placeOrder 7 |> Flow.run () |> shouldEqual (Exit.Success 7)
List.ofSeq finished |> shouldEqual [ "orders.place" ]
listener.Dispose()
SystemDiagnosticsfinished: ResizeArray<string>``.ctor``: unit -> unitInitializes a new instance of the class that is empty and has the default initial capacity.
stringAn abbreviation for the CLI type . Basic Types
listener: ActivityListenerSystem.Diagnostics.ActivityListenerAllows listening to the start and stop activity events and gives the opportunity to decide creating an activity for sampling scenarios.
ShouldListenTo: Func<ActivitySource,bool>Gets or sets the callback that allows deciding if activity object events that were created using the activity source object should be listened or not. to listen events; otherwise.
source: ActivitySourceName: stringReturns the activity source name. A string that represents the activity source name.
(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
Sample: SampleActivity<ActivityContext>Gets or sets the callback that is used to decide if creating objects with a specific data state is allowed. A sample activity instance.
System.Diagnostics.SampleActivity`1A delegate that defines the signature of the callbacks used in the sampling process. The Activity creation options used by callbacks to decide creating the Activity object or not. The type of the requested parent to create the Activity object with. Should be either a string or an instance. An object containing the sampling results, which indicate the amount of data to collect for the related .
System.Diagnostics.ActivityContextA representation that conforms to the W3C TraceContext specification. It contains two identifiers: a TraceId and a SpanId, along with a set of common TraceFlags and system-specific TraceState values.
System.Diagnostics.ActivitySamplingResultEnumeration values used by to indicate the amount of data to collect for the related . Requesting more data causes a greater performance overhead.
AllData: ActivitySamplingResultThe activity object should be populated with all the propagation information and also all other properties such as Links, Tags, and Events. Using this value causes to return .
ActivityStopped: Action<Activity>Gets or sets the callback used to listen to the activity stop event. An activity callback instance used to listen to the activity stop event.
activity: ActivityAdd: string -> unitAdds an object to the end of the . The object to be added to the end of the . The value can be for reference types.
DisplayName: stringGets or sets the display name of the activity. A string that represents the activity display name.
System.Diagnostics.ActivitySourceProvides APIs to create and start objects and to register objects to listen to the events.
AddActivityListener: ActivityListener -> unitAdds a listener to the activity starting and stopping events. The activity listener object to use for listening to the activity events.
placeOrder: int -> Flow<unit,string,int>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Flowrun: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
shouldEqual: 'a -> 'a -> unitAxial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
Microsoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
ofSeq: 'T seq -> 'T listBuilds a new list from the given enumerable object. The input sequence. The list of elements from the sequence. let inputs = seq { 1; 2; 5 } inputs |> List.ofSeq Evaluates to [ 1; 2; 5 ]. This is an O(n) operation, where n is the length of the sequence.
Dispose: unit -> unitUnregisters this activity listener object from listening to activity events.
Activity.traceOn stamps the span with the ambient typed attributes attached through Axial.Telemetry.Context, the
fiber id, every runtime annotation, and, when the workflow settles (so the duration covers asynchronous work), the
exit outcome and error or defect attributes. The Telemetry guide starts with a
complete Aspire setup and shows semantic and application-defined attributes.
What you get without per-callsite work:
- Fiber observability: one edge install of
FiberTelemetry.observerecords a span for every fiber defect and every provably unobserved defect anywhere below it;FiberTelemetry.observeWithSpansupgrades every forked fiber to a real span covering fork to settle. - Host and client spans: ASP.NET Core,
HttpClient, and database instrumentation span their own boundaries. Axial spans nest inside them viaActivity.Current, so in a web application every request is already a trace;Activity.traceOn(or anActivityTracerinstalled ambiently) adds the meaningful interior structure.
Plugging in OpenTelemetry
Because Axial emits through standard ActivitySource instances, the OpenTelemetry SDK is the listener, and there is no
adapter to write. Use an application-owned source for spans around user workflows, and subscribe to "Axial" separately
for automatic runtime and fiber spans.
In an ASP.NET Core or Generic Host application:
// dotnet add package OpenTelemetry.Extensions.Hosting
// dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
// dotnet add package OpenTelemetry.Instrumentation.AspNetCore
let applicationActivitySource = new System.Diagnostics.ActivitySource("MyApp")
builder.Services
.AddOpenTelemetry()
.ConfigureResource(fun resource -> resource.AddService("my-app") |> ignore)
.WithTracing(fun tracing ->
tracing
.AddSource(applicationActivitySource.Name, "Axial")
.AddAspNetCoreInstrumentation() // incoming request spans
.AddOtlpExporter() // collector, Jaeger, Tempo, Honeycomb, ...
|> ignore)
|> ignoreIn a console application or script, build the provider directly and keep it alive for the process lifetime:
open System.Diagnostics
open OpenTelemetry
open OpenTelemetry.Resources
open OpenTelemetry.Trace
use applicationActivitySource = new ActivitySource("MyScript")
use tracerProvider =
Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("my-script"))
.AddSource(applicationActivitySource.Name, "Axial")
.AddOtlpExporter() // or .AddConsoleExporter() to print spans locally
.Build()Then install the edge observers on your application workflow. They are worth having with or without an exporter attached:
let observeApplication (logger: Microsoft.Extensions.Logging.ILogger) (application: Flow<unit, string, int>) =
application
|> Flow.withFiberObserver
(FiberObserver.compose FiberTelemetry.observerWithSpans (FiberLogging.observer logger))Sampling is the host's knob: the SDK samples everything by default, and something like
.SetSampler(TraceIdRatioBasedSampler 0.1) scales that back in production. When the sampler declines,
Axial's StartActivity returns null and the span site costs almost nothing.
For a quick local look without infrastructure, use .AddConsoleExporter(). For a complete runnable setup, the
Axial.ReferenceApp starts an Aspire dashboard and
OTLP receiver and provides an endpoint that generates every Axial observability signal.
Correlation: how the signals join up
- Telemetry context → span attributes.
Context.withEndUserId,Context.withAttribute, andContext.withAttributesscope typed, searchable attributes around a workflow. Both the .NET and JavaScript adapters consume the same ambient context; no environment interfaces or runtime field discovery are involved. - Annotations → every observer.
Flow.annotate"payment.attempt" attemptIdis scoped runtime metadata, not a tracing call: any active trace (Activity.traceOn,Activity.trace, or a tracer's.Trace) tees annotations onto the active span asaxial.flow.annotation.*tags, and custom sinks (Flow.addAnnotationSink) can route the same values into log scopes or anywhere else.Flow.withTraceIdidsets the standardtrace_idannotation for a flow, andFlow.traceIdreads it back. See the runtime operations tutorial. - Fiber ids link spans. Workflow spans and fiber spans both carry
axial.flow.fiber.id(and fiber spansaxial.flow.fiber.parent_id), so fiber-lifecycle spans correlate with the workflows that forked them even when they are not parent/child in the trace tree.
Logs
Logging is deliberately the opposite design from tracing: which logger is an application dependency you
substitute, so ILog is an explicit environment service, not ambient instrumentation.
- Workflows log through
Log.info/Log.error/Log.errorExn/... againstIHasLog. Axial.HostingbridgesILogtoMicrosoft.Extensions.Logging, exceptions included, so entries flow into the host's providers.FiberLogging.observeloggeris the logging counterpart ofFiberTelemetry.observe: fiber defects are logged as errors, unobserved defects as critical entries. Compose both from one edge install as shown above.
OpenTelemetry can also export MEL logs (builder.Logging.AddOpenTelemetry(...)), which pairs naturally with
the bridge: ILog → MEL → OTLP.
Metrics
The .NET counterpart of ActivitySource is System.Diagnostics.Metrics.Meter, and Axial owns one, named
"Axial". FiberMetrics.observe (in Axial.Telemetry) installs a fiber observer that records
runtime health onto it:
| Instrument | Kind | Meaning |
|---|---|---|
axial.flow.fibers.started |
counter | fibers forked |
axial.flow.fibers.live |
up-down counter | fibers currently running |
axial.flow.fibers.settled |
counter, tagged axial.flow.fiber.status |
settles split by Succeeded/Failed/Interrupted |
axial.flow.fiber.duration |
histogram (seconds), tagged with status | fork-to-settle lifetime |
axial.flow.fibers.unobserved_defects |
counter | defects the runtime proved no code could observe |
let observed (application: Flow<unit, string, int>) =
application
|> FiberMetrics.observe // fiber runtime metrics
|> FiberTelemetry.observe // fiber defect spans; installs compose
observed: Flow<unit,string,int> -> Flow<unit,string,int>application: Flow<unit,string,int>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
stringAn abbreviation for the CLI type . Basic Types
intAn abbreviation for the CLI type . Basic Types
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Telemetry.FiberMetricsOpenTelemetry-compatible fiber runtime metrics on the Axial meter. Register the meter with your OpenTelemetry pipeline (AddMeter("Axial")) and every metric appears in any OTLP backend, including the Aspire dashboard's metrics view. Instruments: axial.flow.fibers.started and axial.flow.fibers.settled (counters; settled is tagged with axial.flow.fiber.status), axial.flow.fibers.live (up-down counter), axial.flow.fiber.duration (histogram, seconds, tagged with status), and axial.flow.fibers.unobserved_defects (counter).
observe: Flow<'env,'error,'value> -> Flow<'env,'error,'value>Installs the metrics fiber observer on a flow, composing with any observer already installed, typically once at the application edge, stacked with FiberTelemetry.observe or a FiberRegistry. The source flow. A flow whose forked fibers report runtime metrics on the Axial meter.
Axial.Telemetry.FiberTelemetryTelemetry wiring for runtime fiber-lifecycle observation.
observe: Flow<'env,'error,'value> -> Flow<'env,'error,'value>Installs the telemetry fiber observer on a flow, typically once at the application edge. The source flow. A flow whose forked fibers report defects through the Axial activity source.
observed (placeOrder 3) |> Flow.run () |> shouldEqual (Exit.Success 3)
observed: Flow<unit,string,int> -> Flow<unit,string,int>placeOrder: int -> Flow<unit,string,int>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Flowrun: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
shouldEqual: 'a -> 'a -> unitAxial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
Subscribe with .AddMeter("Axial") in .WithMetrics(...) and the instruments land in any OTLP
backend. A climbing fibers.live with flat fibers.settled is a fiber leak; a nonzero
unobserved_defects rate is crashing background work nobody joins. Plain Task.Run code cannot report either
without hand-rolled bookkeeping.
Host instrumentation (.AddAspNetCoreInstrumentation(), .AddHttpClientInstrumentation(),
.AddRuntimeInstrumentation()) still covers request rates and process health; the public FiberObserver
hooks remain available for app-specific counters on your own meter.
Fiber dumps
A FiberRegistry (core Axial, no telemetry dependency) tracks every live fiber below one edge
install and answers "what is my runtime doing right now?" with a structured snapshot or a rendered tree:
let registry = FiberRegistry()
let withPoller : Flow<ClockEnvironment, string, string> =
flow {
let! clock = Flow.envWith (fun (env: ClockEnvironment) -> env.Clock)
let! poller = Flow.sleep (TimeSpan.FromMinutes 1.0) |> Flow.forkNamed "outbox-poller"
// later, from a diagnostics endpoint, a SIGQUIT-style handler, or a stuck-shutdown log:
let dump = registry.DumpAt(clock)
let! _ = Fiber.interrupt poller
return dump
}
|> Flow.withFiberRegistry registry // composes with observers installed elsewhere
registry: FiberRegistry``.ctor``: unit -> FiberRegistryCreates a registry that remembers the last 200 settled fibers and unobserved defects.
withPoller: Flow<ClockEnvironment,string,string>Axial.Flow`3Represents a cold workflow that reads an environment, returns a typed result, and is executed explicitly through one of its execution members such as ToTask, ToAsync, or RunSynchronously. The type of the environment dependency. The type of the failure value. The type of the success value.
Axial.ClockEnvironmentAn environment containing only a clock, for timed flows with no other services.
stringAn abbreviation for the CLI type . Basic Types
flow: FlowBuilderThe universal flow { } computation expression.
clock: IClockAxial.FlowenvWith: ('env -> 'value) -> Flow<'env,'error,'value>Projects one value from the current environment. This is the primary way to access app dependencies, configuration, or request metadata stored in env. The projection runs only when the flow is executed, so constructing the flow is still pure and side-effect free. Prefer small projections over passing a large environment deeper into reusable helpers. A function that extracts a value from the environment. A containing the projected value. let currentTime () = Flow.envWith (fun (environment: BaseRuntime) -> environment.Clock.UtcNow())
env: ClockEnvironmentClock: IClockpoller: Fiber<obj,unit>sleep: TimeSpan -> Flow<'env,'error,unit>Suspends the flow for the specified duration, observing cancellation. The duration to sleep. A flow that completes after the specified delay, or is interrupted if cancelled first.
System.TimeSpanRepresents a time interval.
FromMinutes: float -> TimeSpanReturns a that represents a specified number of minutes, where the specification is accurate to the nearest millisecond. A number of minutes, accurate to the nearest millisecond. An object that represents . is less than or greater than . -or- is . -or- is . is equal to .
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
forkNamed: string -> Flow<'env,'error,'value> -> Flow<'env,'none,Fiber<'error,'value>>Starts a flow in a new fiber carrying a diagnostic name. The name appears in FiberDump snapshots, FiberRegistry dumps, and telemetry fiber spans, so long-lived background fibers are recognizable in diagnostics instead of showing as bare ids. The diagnostic name recorded in the fiber's metadata. The flow to fork. A flow that produces a handle.
dump: stringDumpAt: IClock -> stringRenders a snapshot of live fibers as a human-readable parent/child tree, timestamped at .
Axial.FiberModuleOperations on a running , the handle returned by Flow.fork. Every operation returns a flow; nothing waits or interrupts until that flow runs. Reading a fiber's outcome through join, await, or interrupt marks it observed, so a defect it died with is not also reported as unobserved.
interrupt: Fiber<'error,'value> -> Flow<'env,'none,Exit<'value,'error>>Signals a fiber to stop, waits for its cleanup, and returns its final exit. Interruption requests cooperative cancellation through the fiber's cancellation source. A fiber that had already settled keeps its original exit. The fiber to interrupt. A flow that completes with the fiber's final outcome after interruption.
withFiberRegistry: FiberRegistry -> Flow<'env,'error,'value> -> Flow<'env,'error,'value>Tracks every fiber forked inside the flow in . The registry's observer is composed with any observer already installed, so telemetry hooks and the registry can coexist from separate installs. Install once at the application edge, keep the registry, and call registry.DumpAt(clock) (or registry.Snapshot()) whenever a live fiber tree is needed. The registry that receives fiber lifecycle events. The source flow. A flow whose forked fibers are tracked in the registry.
match withPoller |> Flow.run (ClockEnvironment Clock.live) with
| Exit.Success dump -> dump.Contains "\"outbox-poller\"" |> shouldEqual true
| other -> failwithf "unexpected %A" other
withPoller: Flow<ClockEnvironment,string,string>(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Axial.Flowrun: 'env -> Flow<'env,'error,'value> -> Exit<'value,'error>Runs the workflow and blocks until the final exit is available. The environment used by the workflow. The workflow to run. The final workflow exit. let exit = workflow |> Flow.run environment
``.ctor``: IClock -> ClockEnvironmentAxial.PlatformService.ClockHelpers for the clock service.
live: IClockCreates a live clock backed by and a monotonic timer.
Axial.Exit`2Represents the final outcome of a workflow execution. The type of the success value. The type of the domain-specific failure value.
SuccessThe workflow completed successfully.
dump: stringContains: string -> boolReturns a value indicating whether a specified substring occurs within this string. The string to seek. if the parameter occurs within this string, or if is the empty string (""); otherwise, . is .
shouldEqual: 'a -> 'a -> unitother: Exit<string,string>failwithf: Printf.StringFormat<'T,'Result> -> 'TPrint to a string buffer and raise an exception with the given result. Helper printers must return strings. The formatter. The formatted result. See Printf.failwithf (link: ) for examples.
Fiber dump @ 2026-07-16T10:00:12.5000000+00:00 — 3 live fiber(s)
#1 "outbox-supervisor" Running 3605.2s (started 2026-07-16T09:00:07.2000000+00:00)
├─ #2 "outbox-poller" Running 12.5s (started 2026-07-16T10:00:00.0000000+00:00) [tenant=acme]
└─ #3 Running 0.4s (started 2026-07-16T10:00:12.1000000+00:00)
Name fibers at the fork site with Flow.forkNamed "outbox-poller" work. The name carries into dumps,
fiber spans, and metrics-adjacent tags, so long-lived background fibers are recognizable instead of bare
ids. Each dump entry also carries the runtime annotations that were in scope at the fork site and, for
settled fibers, the settle timestamp. registry.Snapshot() returns the same data as structured
FiberDump values for programmatic checks; Fiber.dump fiber snapshots a single handle.
The registry also remembers what already finished, which is what a diagnostics screen or a hang report needs:
registry.Settled()returns the most recently settled fibers, oldest first. Each carries its final metadata (status, start and settle times,Duration) and, for a failed fiber, its cause rendered as text, for typed errors as well as defects.registry.Stats()returns totals per fiber name since the registry was installed: how many ran, failed, and were interrupted, with total and longest duration. Unnamed fibers are counted under(unnamed).registry.UnobservedDefects()returns defects that nobody joined or awaited.registry.StartedCountcounts every fiber started.
History is bounded: FiberRegistry() keeps the last 200 settled fibers and unobserved defects, and
FiberRegistry(capacity) sets another bound. Totals are kept for every name. Install the registry with
Flow.withFiberRegistry to get typed failure text; an observer composed by hand sees defects only.
A dump that shows a stuck fiber can also act on it: registry.Interrupt id signals one live fiber to stop, and
registry.InterruptByName "outbox-poller" signals every live fiber with that name and returns how many it signalled.
Neither waits; whoever joins or awaits the fiber sees Cause.Interrupt.
To put a dump where your traces are, FiberDumpTelemetry.record registry attaches the live-fiber tree to
the current activity as an axial.flow.fiber.dump event (or a standalone span when no activity is
current). Record one just before a timeout fires or from a slow-request handler, so the trace that explains
that something was slow also records what the runtime was busy with.
The Aspire dashboard
Nothing Aspire-specific is required: Aspire's dashboard is an OTLP backend, and AddServiceDefaults() in an
Aspire service project already wires the OpenTelemetry SDK. Add your application source, Axial's runtime source, and
Axial's meter to the pipeline:
builder.Services
.AddOpenTelemetry()
.WithTracing(fun tracing -> tracing.AddSource(applicationActivitySource.Name, "Axial") |> ignore)
.WithMetrics(fun metrics -> metrics.AddMeter("Axial") |> ignore)
|> ignoreThe dashboard then shows:
- Traces: application workflow spans from
Activity.traceOn, and withFiberTelemetry.observeWithSpansa span per forked fiber (named fibers display asaxial.flow.fiber <name>), nested under the ASP.NET Core request span. Fiber dump events fromFiberDumpTelemetry.recordappear on the span that recorded them. - Metrics: the
axial.flow.fibers.*instruments as live charts. Watchfibers.liverise and fall under load, and alarm onunobserved_defects. - Structured logs: fiber defects via
FiberLogging.observethrough theILog/MEL bridge.
Distributed tracing across a .NET backend and a Fable frontend
The two telemetry packages join into one distributed trace through the W3C traceparent header. Neither
package does the propagation itself; the OpenTelemetry SDKs do it on both ends:
- The browser app bootstraps OTel JS (
WebTracerProvider,ZoneContextManager, an OTLP exporter, and@opentelemetry/instrumentation-fetch), thenOtel.installNamedapi "Orders.Web". - A user action runs
submitOrder |> Otel.trace "orders.submit". A span starts and becomes the active context. - The workflow calls the backend with
fetch; the fetch instrumentation opens a client span under it and injectstraceparentinto the request. - ASP.NET Core reads
traceparentnatively, so the request span is a remote child of the browser's; with.AddAspNetCoreInstrumentation()it is recorded. - The handler runs
placeOrder |> Activity.traceOn applicationActivitySource "orders.place", nesting under the request span viaActivity.Current.
Both ends export to the same collector, and the trace view shows one tree,
orders.submit → fetch → POST /orders → orders.place, with browser and server spans interleaved. Because
both packages compile the same shared vocabulary source (src/Axial.Telemetry.Shared), the
axial.flow.* attributes mean the same thing on both halves, so one dashboard query spans the stack.
The gotchas are standard browser-OTel operations, not Axial concerns: the API must allow the traceparent
header through CORS and the fetch instrumentation needs propagateTraceHeaderCorsUrls for cross-origin
calls; browsers should export via a collector (CORS again); use parent-based sampling on the server so the
frontend's sampling decision carries through; and without a context manager, a fetch issued after an
awaited boundary loses the active span and starts a fresh trace.
Packaging and platform notes
Why is Axial.Telemetry a separate package? It is the only piece with a dependency beyond core
(System.Diagnostics.DiagnosticSource) and the only piece that is meaningless off .NET. Keeping it out of
Axial keeps the core dependency-free and Fable-compilable; keeping it out of Axial.Hosting
keeps tracing available to console scripts and workers that never touch the generic host. The seams follow
the signals: core owns the neutral channels (annotations, FiberObserver), Telemetry turns them into spans,
Hosting turns them into MEL logs.
Fable / JavaScript. System.Diagnostics.Activity does not exist in JavaScript, so
Axial.Telemetry (and Axial.Hosting) are .NET-only. The JavaScript counterpart is
Axial.Telemetry.JavaScript, which emits through OpenTelemetry JS instead, in Node and the browser
alike, with the same span semantics and axial.flow.* tag vocabulary. It never imports the npm module
itself: the application registers the OpenTelemetry JS SDK (exporter and context manager) and hands the
@opentelemetry/api object to Otel.installNamed once at the edge, the same host/library split as registering an
application ActivitySource on .NET:
open Fable.Core.JsInterop
open Axial.Telemetry.JavaScript
// after registering the OpenTelemetry JS SDK (NodeSDK / WebTracerProvider)
Otel.installNamed (importAll "@opentelemetry/api") "Orders.Web"
application
|> Otel.trace "orders.place" // the JS counterpart of Activity.trace
|> FiberTelemetry.observe // fiber defect spans, as on .NETPlatform caveats: span parenting across awaited boundaries requires the application's OpenTelemetry context
manager (AsyncLocalStorageContextManager on Node, ZoneContextManager in the browser); environment traits
are read structurally because interface type tests are erased in JavaScript; and the GC-based
unobserved-defect net relies on .NET finalization, so under Fable unobserved defects are reported only at the
deterministic detection sites (discarded race/timeout losers and scope close). The package's .NET build is
inert (Otel.install throws and Otel.trace is a pass-through), so shared Fable/.NET source trees compile
without conditional references; on .NET, use Axial.Telemetry.
Where to go deeper
- Telemetry: span tag vocabulary,
Activity.traceWith, span-per-fiber details. - Supervision and fiber observability:
FiberObserver,Flow.supervise, unobserved-defect semantics. - Hosting: DI integration and the
ILog/MEL bridge. - Runtime operations tutorial: annotations, timeout, retry, cancellation in practice.

