Debugging Endpoints
Endpoints run hot — the workspace recompiles on save — which makes the inner loop fast. These tips keep the loop tight when the endpoint isn't doing what you expect.
1. Prototype in the shell
The shell at Management → Shell runs in the same scope as an endpoint (you get Q(), Graph, Logger, etc.). Use it to iterate on the graph query before pasting it into the endpoint body:
// Prototype query in the shell
var hits = Q().StartAt("SupportCase")
.SortByTimestamp(oldestFirst: false)
.Take(5)
.Emit("N")
.ToList();
return hits;
When the shape looks right, copy it into the endpoint and wrap with Ok(...).
2. Use RelayStatusAsync for live feedback
RelayStatusAsync(message) streams a status line back to the caller while the endpoint runs. Invaluable for diagnosing where time is going in pooling endpoints:
await RelayStatusAsync("Resolving siblings...");
var siblings = ResolveSiblings();
await RelayStatusAsync($"Running search ({siblings.Length} candidates)...");
var hits = await SearchAsync(siblings);
The status messages show up in the chat UI and in MSK-ENDPOINT-STATUS response headers.
3. Log liberally
Logger.LogInformation("endpoint={Endpoint} user={UID} query={Query}",
"kb-chat", CurrentUser, req.Query);
Stdout from Logger flows to Management → Logs and to your aggregator if you've wired one. Prefer structured logging over LogInformation($"... {value} ...") because aggregators index the fields.
For debugging, LogDebug shows in the workspace UI but not in production aggregators by default — perfect for noisy diagnostics.
4. Return intermediate values
During development, swap Ok(finalResult) for an object that includes the intermediate state:
return Ok(new {
inputs = req,
siblings,
hits = hits.Select(h => new { h.Node["Id"], h.Score }),
answer
});
Roll back once the endpoint behaves correctly — production responses should be lean.
5. Respect CancellationToken
For long-running endpoints, pass CancellationToken through every awaitable and check it manually around long synchronous loops:
foreach (var record in batch)
{
CancellationToken.ThrowIfCancellationRequested();
Map(record);
}
A pooling endpoint that doesn't honor cancellation can keep an LLM call running long after the caller has hung up — money down the drain.
6. Call one endpoint from another (cheap fixtures)
RunEndpointAsync<T>("path", body) runs a sibling endpoint inheriting the current user — great for writing a tiny test endpoint that hits the real one:
// Endpoint: kb-chat-smoke
var reply = await RunEndpointAsync<ChatReply>(
"kb-chat",
new { Question = "smoke", K = 3 });
return Ok(new
{
HasAnswer = !string.IsNullOrEmpty(reply.Answer),
HasCitations = reply.Citations.Length > 0,
Snippet = reply.Answer[..Math.Min(80, reply.Answer.Length)],
});
For write endpoints from a replica, use RunEndpointOnPrimaryAsync<T> to forward to the primary node.
7. Inspect what CurrentUser resolves to
When debugging permissions, dump the caller's UID and role early:
var u = await Graph.GetUserByUidAsync(CurrentUser);
Logger.LogInformation("caller={Login} isAdmin={IsAdmin}", u?.Login ?? "(token)", u?.IsAdmin == true);
If CurrentUser is default, you're being called by an endpoint token — see Security best practices.
Common diagnostic patterns
| Symptom | Try |
|---|---|
| Empty results for some users | Compare CreateSearchAsync vs CreateSearchAsUserAsync output. |
Timeout in Sync mode |
Switch to Pooling; verify with RelayStatusAsync where time goes. |
| Endpoint compiles but never returns | Missing Ok(...) / return in a branch. |
| "Body could not be deserialized" | Logger.LogInformation the raw Body string and compare to JSON spec. |
RunEndpointAsync failing with 403 |
Endpoint requires admin, but caller isn't admin; pass runAsAdmin: true (carefully) or change the target. |
Cross-links
- Execution scopes — every helper you can call.
- Creating endpoints
- Security best practices