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.HttpClient
open Axial.PlatformService
open Axial.Process
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScriptObservability
This page is the map: what each observability signal is, which Axial package produces it, what you get automatically versus what you opt into, and the one-time wiring that sends it all 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 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.Dump() 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 — 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:
open System.Diagnostics
open Axial.Telemetry
let applicationActivitySource = new ActivitySource("Orders.Application")
let placeOrder order =
flow { (* 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: 'a -> Flow<'b,'c,unit>order: 'aflow: 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 and renders typed errors with string. 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.
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 — 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)
|> ignoreopen 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()open Axial.Hosting
open Axial.Telemetry
application
|> Flow.withFiberObserver
(FiberObserver.compose FiberTelemetry.observerWithSpans (FiberLogging.observer logger))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. 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.observe loggeris 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 |
application
|> FiberMetrics.observe // fiber runtime metrics
|> FiberTelemetry.observe // fiber defect spans — installs composeHost 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()
application
|> Flow.withFiberRegistry registry // composes with observers installed elsewhere
// later — a diagnostics endpoint, a SIGQUIT-style handler, a stuck-shutdown log:
printfn "%s" (registry.Dump())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.
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) — useful 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)
|> ignore- 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.livebreathe 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 — that is the OpenTelemetry SDKs' job on both ends:
- The browser app bootstraps OTel JS (
WebTracerProvider,ZoneContextManager, an OTLP exporter, and@opentelemetry/instrumentation-fetch), thenOtel.installNamed api "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 .NETWhere to go deeper
- Telemetry — span tag vocabulary,
Activity.traceWith, span-per-fiber details. - Supervision and fiber observability —
FiberObserver,Flow.Runtime.supervise, unobserved-defect semantics. - Hosting — DI integration and the
ILog/MEL bridge. - Runtime operations tutorial — annotations, timeout, retry, cancellation in practice.

