Skip to content

Observability

Arc instruments its own pipelines. Every command and query that runs produces activities on a named source and measurements on a named meter, so once you subscribe to them your tracing backend shows where time went inside Arc, and why an operation failed, without you adding anything to your handlers.

This is emitted telemetry rather than an extension point. Arc registers the activity sources itself; what you do is subscribe to them.

Arc publishes its spans on an activity source named Cratis.Arc and its metrics on a meter of the same name. Both names are available as constants on WellKnownDiagnostics, so you do not have to repeat the string:

using Cratis.Arc;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource(WellKnownDiagnostics.ActivitySourceName)
.AddAspNetCoreInstrumentation())
.WithMetrics(metrics => metrics
.AddMeter(WellKnownDiagnostics.MeterName)
.AddAspNetCoreInstrumentation());

Nothing else is required. The sources are registered when the host is built, so a subscriber either sees the spans or does not — there is no Arc-side switch to turn tracing on.

All of these are Internal activities. Each span keeps one stable name, which is what tracing backends show as the span name and what you filter and group on. The command or query it covers is an attribute, cratis.arc.command.type or cratis.arc.query.name, so a dashboard keyed on a span name keeps working as your application adds commands and queries.

SpanRaised when
cratis.arc.command.executea command runs through the pipeline
cratis.arc.command.validatea command is validated without being run
cratis.arc.command.filterthe command filters run
cratis.arc.command.authorizean authorization filter decides on the command
cratis.arc.validator.invokea validator runs, for a command, a query or a nested model
cratis.arc.command.providea model-bound command’s Provide() method runs
cratis.arc.command.handlethe command handler runs
cratis.arc.command.actiona controller-based command action is invoked
cratis.arc.query.performa query runs through the pipeline
cratis.arc.query.filtera query filter runs
cratis.arc.query.actiona controller-based query action is invoked
cratis.arc.identity.resolveidentity details are resolved for a request

The child spans nest inside the command span, which itself nests inside the request span your ASP.NET Core instrumentation already creates. For a RegisterAuthor command, the execute, authorize, provide and handle spans carry cratis.arc.command.type = MyApp.Authors.RegisterAuthor, and the validator span carries the validator type:

POST /api/authors/register
└── cratis.arc.command.execute
├── cratis.arc.command.filter
│ ├── cratis.arc.command.authorize
│ └── cratis.arc.validator.invoke
├── cratis.arc.command.provide
└── cratis.arc.command.handle

A validator runs once for every instance of the type it validates, so a command carrying a list of 500 items would otherwise add 500 spans. A validator gets a span the first time it runs in a validation, not for each item after that, and one validation adds at most 16 validator spans.

For a request over HTTP, the command or query span starts before Arc prepares authorization, so a request turned away at that point still has a span and is still counted in the metrics.

AttributeOnValue
cratis.arc.command.typeexecute, validate, authorize, provide, handlethe full name of the command type
cratis.arc.command.outcomeexecute, validatesuccess, validation, authorization, append_rejected, cancelled or error
cratis.arc.command.key.typeexecute, validatethe type the command declares its key as, such as its event source id with the Chronicle integration
cratis.arc.query.nameperformthe fully qualified name of a known query; absent for a name that matches no query
cratis.arc.query.transportperformsnapshot, observable, or unknown when the query failed before it ran
cratis.arc.query.outcomeperformsuccess, validation, authorization, cancelled or error
cratis.arc.validator.typecratis.arc.validator.invokethe full name of the validator type
cratis.arc.validation.result_countcratis.arc.validator.invokehow many results the validator produced
cratis.correlation_idexecute, validate, performthe correlation id of the operation
cratis.tenantexecute, validate, performthe tenant the operation runs for, when one was resolved while it ran; a query records it only when it was authorized for a principal

The execute, validate, filter and perform spans also keep the command_type and query_name attributes they carried in earlier versions. query_name is _other for a name that matches no query. Prefer the cratis.arc.* attributes for anything new.

Arc records the tenant once the pipeline has established who the operation runs as, without resolving a tenant itself, since that would change what the operation sees. The tenant is held per asynchronous flow, so one that a filter or validator resolves inside its own call is not visible to the pipeline afterwards. An operation turned away before that point, for example a command with no handler, carries no tenant.

The outcome attribute says how an operation ended. Following OpenTelemetry, only an error outcome sets the span status to Error: a failure that is not a validation, authorization or event store rejection and not a cancellation. A command rejected by validation, by authorization or by the event store is the application working as intended, so its span status stays unset; filter on the outcome attribute to find those. cancelled means the operation’s cancellation token was cancelled, normally because the caller went away, and it leaves the status unset too.

The child spans follow the same rule. An exception thrown from Handle(), Provide() or a validator adds an exception event to that span, but sets its status to Error only when it is an error: not when it is a validation failure (an exception implementing IValidationFailure, which Arc reports as a validation outcome) and not once the operation was cancelled.

Depending on how an operation ended, events on its span say why:

EventRaised whenAttributes
cratis.arc.validation.failedonce for each validation result that blocked the operation, up to 16cratis.arc.validation.severity, cratis.arc.validation.members, cratis.arc.validation.reason and, when set, cratis.arc.validation.reason_detail
cratis.arc.authorization.deniedauthorization denied the operation, on the operation span and the authorize span—
exceptionan exception was thrown, on the span it was thrown in and on the command or query span that turned it into a resultexception.type

append_rejected is the outcome when the rejection came from the event store rather than from a rule: a constraint violation or a concurrency conflict. The reason says which, and for a constraint the reason detail is the constraint name.

Spans carry types, names, identifiers, member names and outcomes. They never carry a value from a command, query or event, so a property marked [PII] or [NotAudited] cannot reach your tracing backend through them. That is why a validation event names the member and the rule but not the message, which often quotes the value, and why an exception event carries the exception type but not its message or stack trace; those stay in your logs. The event source id is recorded by its type, never by its value.

Every span, attribute, event and metric name in this page is a constant on WellKnownTelemetryNames, and every outcome is a constant on WellKnownOperationOutcomes, so dashboards, alerts and tests built in C# can use them instead of repeating the strings.

The split between validate and execute is the one worth watching. Validation that reaches a database — a uniqueness rule, a state-dependent check — is easy to write and easy to forget, and it shows up here as time spent before the handler ever ran.

cratis.arc.identity.resolve is the other common surprise. The span covers each identity lookup, such as the frontend’s /.cratis/me request. Every lookup calls your identity-details provider, so one that queries a store puts that query on the critical path of every lookup.

Arc records these on the Cratis.Arc meter:

MetricTypeUnitAttributes
cratis.arc.command.durationhistogramscratis.arc.command.type, cratis.arc.command.outcome
cratis.arc.command.outcomescounter{command}cratis.arc.command.type, cratis.arc.command.outcome
cratis.arc.query.durationhistogramscratis.arc.query.name, cratis.arc.query.transport, cratis.arc.query.outcome

A command validated without being run is traced but not counted. For an observable query the duration covers setting up the subscription, not how long it stays open.

Each metric records at most 1,000 distinct command types or query names. The limit is fixed; there is no option to change it. Past that, further ones are recorded as _other, so a metric backend never has to hold an unbounded number of series. The limit holds exactly, however many requests arrive at once; only right at the limit can a new name be recorded as _other once before it gets the last free place. A query name that matches no known query is always recorded as _other.

When nothing listens to the meter or the activity source, Arc skips most of the work of recording: it starts no child spans, builds no attributes or events, and does not look up whether a query name is known. It still checks whether anything listens on every operation.

The MongoDB integration records its client metrics on the same meter.

The Kotlin and Java backend observes the same pipeline stages but through Micrometer rather than ActivitySource, with its own names and an explicit correlation attribute. See Observability for that side. The two are not wire-compatible with each other and are not meant to be — each uses what its ecosystem already collects.