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.JavaScriptTrace workflows and inspect them in Aspire
Telemetry answers three different questions:
- What happened? A trace shows the spans that ran and how long each one took.
- Why did it fail? Span status and attributes distinguish typed failures, defects, and interruption.
- What was still running? Fiber metrics and dumps expose background work that an ordinary task trace can miss.
Axial publishes standard .NET ActivitySource spans and Meter instruments. OpenTelemetry collects those signals and
exports them to a backend. The .NET Aspire dashboard is the fastest way to see the
result locally.
Use Axial.Telemetry on .NET. For Node and browser applications compiled with Fable, use
Axial.Telemetry.JavaScript. Both adapters read the same ambient Context and emit the same
axial.flow.* vocabulary.
Before you begin
Install the .NET adapter and the OpenTelemetry host packages:
dotnet add package Axial.Telemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
You need an OTLP receiver such as the Aspire dashboard, an OpenTelemetry Collector, Jaeger, Tempo, or a hosted observability service. Axial does not choose an exporter or send telemetry by itself. The runnable Axial.ReferenceApp configures the SDK and OTLP exporters, launches a local Aspire dashboard, and generates traces, metrics, logs, and a fiber dump from one endpoint.
Configure OpenTelemetry once
Create an application-owned ActivitySource, then subscribe to both that source and Axial's runtime source at the host
boundary:
open System.Diagnostics
let applicationActivitySource = new ActivitySource("Checkout.Api")
builder.Services
.AddOpenTelemetry()
.ConfigureResource(fun resource ->
resource.AddService("checkout-api") |> ignore)
.WithTracing(fun tracing ->
tracing
.AddSource(applicationActivitySource.Name, "Axial")
.AddAspNetCoreInstrumentation()
.AddOtlpExporter()
|> ignore)
.WithMetrics(fun metrics ->
metrics
.AddMeter("Axial")
.AddOtlpExporter()
|> ignore)
|> ignoreThese names answer different questions:
checkout-apiis the service name. Aspire groups telemetry by the deployed application or service that produced it.Checkout.Apiis the application's instrumentation scope. Workflow spans describing checkout behavior should use this source rather than appearing to be operations owned by Axial.Axialis Axial's runtime instrumentation scope. Automatic fiber spans and fiber metrics remain here because they describe the Flow runtime.checkout.submitis the span name for one operation.
An ActivitySource only publishes spans. The OpenTelemetry SDK listens, samples, and exports them. Register every source
used by Activity.traceOn, Activity.traceWithSource, or captured in an ActivityTracer; otherwise .NET returns
null from StartActivity and the workflow runs without a span.
Trace a workflow
Wrap a workflow at a boundary that has an operational meaning. Pass the application source because this span describes user code; Axial supplies the Flow-aware tracing behavior around it:
open Axial.Telemetry
checkout order
|> Activity.traceWithSource applicationActivitySource CheckoutError.describe "checkout.submit"Activity.traceWithSource accepts the source and a renderer for the workflow's typed error. Use Activity.traceOn when
string is an acceptable representation:
checkout order
|> Activity.traceOn applicationActivitySource "checkout.submit"Trace without repeating the source at every call site
Passing applicationActivitySource into every traceOn call gets repetitive once tracing spreads across a codebase.
Capture the source once as an ActivityTracer, and call .Trace on it wherever you need a span:
let checkoutTracer = ActivityTracer.create applicationActivitySource
checkout order
|> checkoutTracer.Trace "checkout.submit"For a workflow tree that traces from many places, install the tracer once near the composition root instead, and call
the ambient Activity.trace/Activity.traceWith from anywhere underneath it — no source to pass or capture at the
call site at all:
application
|> Activity.withTracer checkoutTracer
// deep inside the workflow tree, in code that has no reference to checkoutTracer:
checkout order |> Activity.trace "checkout.submit"Activity.trace and Activity.traceWith read the tracer installed by the nearest enclosing Activity.withTracer;
forked child fibers inherit it too. There is no default source to fall back to: calling Activity.trace outside a
withTracer scope throws immediately, so a workflow can never end up silently tagged under the wrong instrumentation
scope. Use the ambient form for workflow trees under one application source, and traceOn/traceWithSource (or a
second ActivityTracer) when a call site needs a different source than the one currently installed.
The span starts when the workflow runs and stops when its asynchronous execution settles. Axial records:
| Exit | Span status | Attributes |
|---|---|---|
| success | Ok |
axial.flow.outcome = success |
| typed failure | Error |
axial.flow.outcome = fail, axial.flow.error |
| defect | Error |
axial.flow.outcome = die, exception.* |
| interruption | unset | axial.flow.outcome = interrupt, axial.flow.interrupted = true |
| composite cause | dominant outcome | axial.flow.cause with the rendered cause tree |
Do not trace every combinator. Trace operations that you would search for in an incident: checkout.submit,
invoice.generate, or outbox.deliver.
Add attributes
An attribute adds searchable context to the active span. Axial stores attributes in an immutable ambient Context.
The context is separate from the workflow environment because it is execution metadata, not an application service.
Nested scopes restore the previous value when they finish, and forked fibers inherit the context present at the fork.
Use a curated OpenTelemetry helper for a common semantic attribute:
checkout order
|> Context.withEndUserId user.Id
|> Activity.traceWithSource applicationActivitySource CheckoutError.describe "checkout.submit"enduser.id is an OpenTelemetry semantic-convention attribute, currently marked development by OpenTelemetry. It
can contain identifying information; use a stable opaque identifier only when your privacy policy permits exporting it.
Axial documents the stability and scope of every convention for which it provides a convenience helper. Configure resource attributes such as service identity in the OpenTelemetry SDK instead of
copying them onto every span.
Define typed keys for application attributes:
module CheckoutAttributes =
let tenantId = AttributeKey.string "example.tenant.id"
let retryCount = AttributeKey.int64 "example.checkout.retry_count"
Core_11-observability_01-telemetry__index.md_page.CheckoutAttributestenantId: AttributeKey<string>Axial.Telemetry.AttributeKeyCreates typed keys for application-defined telemetry attributes.
string: string -> AttributeKey<string>Creates a string-valued attribute key.
retryCount: AttributeKey<int64>int64: string -> AttributeKey<int64>Creates a 64-bit integer-valued attribute key.
Attach values without boxing or runtime conversion:
checkout order
|> Context.withAttributes [
Context.attribute CheckoutAttributes.tenantId tenantId
Context.attribute CheckoutAttributes.retryCount 2L
]
|> Activity.traceWithSource applicationActivitySource CheckoutError.describe "checkout.submit"A typed key prevents attaching an integer to a string attribute. Supported values are strings, Booleans, 64-bit integers, floating-point values, and homogeneous lists of those types.
Build a context once when several workflows share the same metadata:
let requestContext =
Context.empty
|> Context.addEndUserId user.Id
|> Context.add (Context.attribute CheckoutAttributes.tenantId tenantId)
application
|> Context.withContext requestContextRead the currently scoped context from a workflow when an integration needs to inspect it:
flow {
let! telemetryContext = Context.current
return exportContext telemetryContext
}Most workflows should scope attributes rather than read the whole context. Adapters use Context.current at integration
boundaries; application dependencies still belong in the Flow environment.
Attribute names are contracts with dashboards and alerts. Follow OpenTelemetry semantic conventions when one applies. Use an application-owned prefix otherwise. Do not attach secrets, unrestricted personal information, or high-cardinality values to metrics. A correlation value that must cross process boundaries may belong in OpenTelemetry baggage; trace identity itself belongs in trace and span context, not in a duplicate attribute.
Observe fibers
A successful root workflow can still have failed background work. Install fiber telemetry once around the application:
application
|> FiberTelemetry.observeThis records defect spans for failed fibers and for defects that no code can observe. To create a span for every forked
fiber, use FiberTelemetry.observeWithSpans:
application
|> FiberTelemetry.observeWithSpansSpan-per-fiber mode provides more detail and more data. Start with defect-only observation, then enable span-per-fiber when you need fork-to-settle timing or a complete concurrency tree.
Add runtime metrics independently:
application
|> FiberMetrics.observe
|> FiberTelemetry.observeThe Axial meter records starts, live fibers, settlements, duration, and unobserved defects. A rising live-fiber count
without matching settlements indicates stuck or leaked work.
Capture a fiber dump
A trace explains completed and timed operations. A fiber dump shows the work that is live now.
Install a registry at the application edge:
let registry = FiberRegistry()
let observedApplication =
application
|> Flow.withFiberRegistry registry
|> FiberMetrics.observe
|> FiberTelemetry.observeName long-lived work so the dump is readable:
Flow.forkNamed "outbox-poller" pollOutboxReturn registry.Dump() from a protected diagnostics endpoint, write it during a stuck shutdown, or attach it to the
current trace:
FiberDumpTelemetry.record registryFiberDumpTelemetry.record adds an axial.flow.fiber.dump event to the current activity. If no activity is current, it
creates a standalone span. This connects a slow or failed trace to the exact fibers that were running when you captured
the dump. See Supervision and fiber observability for registry and dump details.
View Axial in the Aspire dashboard
Aspire's dashboard accepts OTLP telemetry. The quickest complete example is
Axial.ReferenceApp: run
dotnet run --file apphost.cs from its directory, then call its /observability/demo endpoint as described in the
example README.
If your Aspire service uses AddServiceDefaults(), keep that setup and add the application source, Axial's runtime
source, and Axial's meter:
builder.Services
.AddOpenTelemetry()
.WithTracing(fun tracing ->
tracing.AddSource(applicationActivitySource.Name, "Axial") |> ignore)
.WithMetrics(fun metrics -> metrics.AddMeter("Axial") |> ignore)
|> ignoreRun the application and open the dashboard:
- Open Traces and select an incoming request.
- Expand the request span to find
checkout.submitor anotherActivity.tracespan. - Inspect its status and attributes, including
enduser.id, application attributes, andaxial.flow.outcome. - Enable
FiberTelemetry.observeWithSpansto see named child fibers in the same trace. - Trigger
FiberDumpTelemetry.recordand inspect theaxial.flow.fiber.dumpevent on the active span. - Open Metrics and chart
axial.flow.fibers.liveandaxial.flow.fibers.unobserved_defects.
This is the intended feedback loop: traces identify the slow or failed operation, attributes identify its application context, metrics show whether the runtime is degrading, and a fiber dump shows the work still in flight.
Choose attributes, annotations, or logs
- Use telemetry context attributes for values intentionally exported to tracing backends and queried across spans.
- Use
Flow.annotatefor Axial runtime diagnostics. Telemetry exports these underaxial.flow.annotation.*, preserving their separate namespace. - Use
ILogfor messages and exceptions that belong in the host logging pipeline.
The mechanisms share ambient scoping, but they are different operational contracts.

