Skip to content

Query health endpoint

Arc provides a live health feed at /.cratis/queries/health. The feed is itself a model-bound observable query, publishing the subscription state recorded by the hub health tracker.

When something goes wrong with real-time data — a component freezes, a query never receives its first result, or a user reports stale data — the question is always: is the issue in the frontend cache, the transport connection, or the backend subscription? The health endpoint gives you a backend-authoritative answer without attaching a debugger or tailing logs.

The query identifiers in the health snapshot use the same fully-qualified format ({TypeFullName}.{MethodName}) that the proxy generator writes into the generated TypeScript proxies. You can match a frontend cache entry to a backend subscriber by name alone.

For a direct stream from a trusted diagnostic client, use the configured host origin and normal credentials:

Terminal window
curl --no-buffer --max-time 30 --header 'Accept: text/event-stream' \
'https://localhost:5001/.cratis/queries/health'

For hub subscriptions, use the fully qualified name Cratis.Arc.Queries.QueryHealth.ObserveHealth and the hub subscription lifecycle. Do not infer a generated TypeScript import path from the HTTP route; use the proxy actually generated into your application’s configured output directory.

This complete application filter denies the named health query unless the current caller is authenticated with the QueryDiagnostics role. It is discovered as a model-bound authorization query filter and therefore applies to both the generated health endpoint and named hub subscriptions, despite the built-in query’s [AllowAnonymous] attribute:

using System.Threading.Tasks;
using Cratis.Arc.Authorization;
using Cratis.Arc.Queries;
namespace Application.Security;
public class RestrictQueryHealth(ICurrentPrincipalAccessor principalAccessor) : IAuthorizationQueryFilter
{
public Task<QueryResult> OnPerform(QueryContext context)
{
if (context.Name.Value != "Cratis.Arc.Queries.QueryHealth.ObserveHealth")
{
return Task.FromResult(QueryResult.Success(context.CorrelationId));
}
var principal = principalAccessor.Current;
var permitted = principal?.Identity?.IsAuthenticated == true &&
principal.IsInRole("QueryDiagnostics");
return Task.FromResult(permitted
? QueryResult.Success(context.CorrelationId)
: QueryResult.Unauthorized(context.CorrelationId));
}
}

Deploy this only with real authentication/role mapping and verify the filter is discovered. Test anonymous, authenticated non-role, and role-authorized callers against direct GET/QUERY, direct SSE/WebSocket, and both hub transports. This filter does not protect arbitrary MVC actions and does not re-check a previously authorized live stream; use emission guards for ongoing revocation.

As an immediate deployment boundary, keep the entire Arc host private behind a trusted network or authenticated gateway. If restricting routes instead, include the direct health route and all hub/control routes or enforce the named-query filter above; exposing the hub while hiding only /health is insufficient. The current built-in anonymous attribute is not changed by an invented health-role option.

A single snapshot has two views of recorded subscription state: connection-centric and query-centric. These fields describe the model inside QueryResult.data, not the outer Arc envelope. Date values are ISO strings on the JSON wire; a generated client may deserialize them to Date.

FieldTypeDescription
connectionsQueryConnectionHealth[]One entry per tracked hub connection (WebSocket or SSE); empty entries may remain briefly after unsubscribe.
totalConnectionsnumberTotal count of tracked connections.
totalSubscriptionsnumberTotal count of recorded subscriptions across tracked connections.
querySubscriptionsQuerySubscriptionAggregate[]Query-centric view — one entry per distinct query name.
FieldTypeDescription
connectionIdstringUnique connection identifier. WebSocket connections use an incrementing ws-N label; SSE connections use a GUID.
protocolstringWebSocket or SSE.
establishedAtDateWhen the tracker first registered a subscription for the connection.
subscriptionsQuerySubscriptionMetadata[]All subscriptions routed through this connection.
FieldTypeDescription
subscriptionIdstringThe client-generated query ID (queryId in the WebSocket protocol).
queryIdentifierstringFully-qualified query name — matches queryName on the generated TypeScript proxy.
readModelTypestringFully-qualified name of the read model type only (without the method).
connectedAtDateWhen this subscription was first established.
clientInfoQuerySubscriptionClientInfoRemote IP, user agent, user identity, and protocol.
lastPingSentAtDate?Last time a keep-alive ping was sent to this subscriber.
lastPongReceivedAtDate?Last time a pong was received back.
lastDataServedAtDate?Last time a data frame was sent to this subscriber.

The query-centric view groups all physical subscribers for a single query name into one entry. The queryName field uses the same identifier format as the frontend proxy’s queryName property, making it straightforward to correlate the two sides.

FieldTypeDescription
queryNamestringFully-qualified query name — identical to queryName on the generated TypeScript proxy.
totalSubscriptionsnumberNumber of physical subscribers for this query.
subscribersQuerySubscriber[]One entry per physical connection/subscription pair.
FieldTypeDescription
connectionIdstringThe parent connection this subscriber belongs to.
protocolstringWebSocket or SSE.
subscriptionIdstringThe client-generated subscription identifier.
connectedAtDateWhen this subscription was established.
clientInfoQuerySubscriptionClientInfoRemote IP, user agent, user identity, and protocol.
lastPingSentAtDate?Last ping sent.
lastPongReceivedAtDate?Last pong received.
lastDataServedAtDate?Last data frame sent.
FieldTypeDescription
remoteIpAddressstring?Client IP address.
userAgentstring?Browser or client user-agent string.
userIdstring?Authenticated user identity, if any.
protocolstringWebSocket or SSE.

Illustrative QueryResult.data value, not a captured response or the full envelope:

{
"connections": [
{
"connectionId": "ws-1",
"protocol": "WebSocket",
"establishedAt": "2026-06-10T14:03:00Z",
"subscriptions": [
{
"subscriptionId": "all-authors-main",
"queryIdentifier": "MyApp.Authors.Listing.AllAuthors",
"readModelType": "MyApp.Authors.Listing",
"connectedAt": "2026-06-10T14:03:01Z",
"lastPingSentAt": "2026-06-10T14:04:00Z",
"lastPongReceivedAt": "2026-06-10T14:04:00Z",
"lastDataServedAt": "2026-06-10T14:03:01Z",
"clientInfo": {
"protocol": "WebSocket",
"remoteIpAddress": "127.0.0.1",
"userAgent": "Mozilla/5.0 ...",
"userId": "alice@example.com"
}
}
]
}
],
"totalConnections": 1,
"totalSubscriptions": 1,
"querySubscriptions": [
{
"queryName": "MyApp.Authors.Listing.AllAuthors",
"totalSubscriptions": 1,
"subscribers": [
{
"connectionId": "ws-1",
"protocol": "WebSocket",
"subscriptionId": "all-authors-main",
"connectedAt": "2026-06-10T14:03:01Z",
"lastPingSentAt": "2026-06-10T14:04:00Z",
"lastPongReceivedAt": "2026-06-10T14:04:00Z",
"lastDataServedAt": "2026-06-10T14:03:01Z",
"clientInfo": {
"protocol": "WebSocket",
"remoteIpAddress": "127.0.0.1",
"userAgent": "Mozilla/5.0 ...",
"userId": "alice@example.com"
}
}
]
}
]
}

The hub records both multiplexed WebSocket and multiplexed SSE subscriptions. WebSocket connections have ws-N identifiers; SSE hub connections have GUID identifiers and may carry multiple subscriptions each. Do not confuse this with direct per-query SSE.

The current registration call sites are in the demultiplexer. Do not treat the feed as a complete inventory of direct per-query WebSocket/SSE connections, or of idle hub connections with no registered subscriptions. querySubscriptions groups the subscriptions that were actually recorded; absence from this feed alone does not prove no direct client is watching a query.

The queryName in QuerySubscriptionAggregate and the queryIdentifier in QuerySubscriptionMetadata both use the format {TypeFullName}.{MethodName} — the same string the proxy generator writes as the queryName field on generated TypeScript proxy classes. Because both sides share the same identifier, you can match a frontend cache entry to its backend subscriptions by name.

For example, if the frontend diagnostics report that the cache entry for MyApp.Authors.Listing.AllAuthors is not subscribed, you can verify in the backend health feed whether a subscription for that name exists at all, which connection carries it, and when data was last served.

See Observable Query Diagnostics for how to access the matching frontend diagnostics.