Skip to content

MetricEvent

C# · package Toolnexus · SPEC §8 · Metrics.cs

public sealed record MetricEvent
{
public required string Event { get; init; } // "llm" | "tool" | "run"
public string? Model { get; init; }
public string? Status { get; init; } // "ok" | "error" (llm)
public long PromptTokens { get; init; }
public long CompletionTokens { get; init; }
public string? Tool { get; init; }
public string? Source { get; init; }
public bool IsError { get; init; }
public bool Pending { get; init; } // §10: a suspension, never IsError
public int Turns { get; init; }
public int ToolCalls { get; init; }
public long TotalTokens { get; init; }
public string? Error { get; init; }
public long Ms { get; init; }
}

A discriminated event — Event is "llm", "tool", or "run", and only the fields for that kind are populated. Every RunAsync/StreamAsync/TranslateAsync call feeds the same events to two places at once: your OnMetric sink (if set) and the client’s own cumulative Prometheus registry, rendered as text by client.Metrics(). MetricEvent’s C# fields are PascalCase (idiomatic for this port) — only the rendered Prometheus text from Metrics() is byte-identical across ports; the semantic event shape is per-language idiomatic.

Forwarding call-level observability to wherever your app already sends it — statsd, structured logs, OpenTelemetry — without scraping /metrics. It’s also how you’d build a live “tokens spent this session” counter or alert on a rising tool error rate, since each event lands the instant the call it describes finishes.

1. The smallest useful sink — count llm events

Section titled “1. The smallest useful sink — count llm events”
using System.Net;
using System.Text;
using Toolnexus;
using var stub = new Stub(ctx =>
{
Stub.Json(ctx, 200, """
{"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}
""");
});
var events = new List<MetricEvent>();
var client = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl,
Style = "openai",
Model = "gpt-4o-mini",
ApiKey = "test-key",
OnMetric = ev => events.Add(ev),
});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
await client.RunAsync("hi", tk);
// One "llm" event and one "run" event for a single tool-free turn.
if (!events.Any(e => e.Event == "llm" && e.Status == "ok")) throw new Exception("missing llm ok event");
if (!events.Any(e => e.Event == "run")) throw new Exception("missing run event");
Console.WriteLine($"ok: {events.Count} event(s): {string.Join(",", events.Select(e => e.Event))}");
sealed class Stub : IDisposable
{
readonly HttpListener _listener = new();
readonly CancellationTokenSource _cts = new();
public int Port { get; }
public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler)
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
_listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
_listener.Start();
_ = Task.Run(async () =>
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
try { handler(ctx); } catch { }
}
});
}
public static void Json(HttpListenerContext ctx, int status, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.OutputStream.Close();
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}

2. A tool event, and telling a suspension apart from a real error

Section titled “2. A tool event, and telling a suspension apart from a real error”
using System.Net;
using System.Text;
using Toolnexus;
var calls = 0;
using var stub = new Stub(ctx =>
{
calls++;
if (calls == 1)
{
Stub.Json(ctx, 200, """
{"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"add","arguments":"{\"a\":1,\"b\":1}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}}
""");
}
else
{
Stub.Json(ctx, 200, """
{"id":"c2","choices":[{"message":{"role":"assistant","content":"1+1=2"},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}
""");
}
});
var add = NativeTool.Of("add", "Add two integers", null,
(IDictionary<string, object?> a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString());
var toolEvents = new List<MetricEvent>();
var client = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl,
Style = "openai",
Model = "gpt-4o-mini",
ApiKey = "test-key",
OnMetric = ev => { if (ev.Event == "tool") toolEvents.Add(ev); },
});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List<ITool> { add } });
await client.RunAsync("stop after adding 1+1", tk);
if (toolEvents.Count != 1) throw new Exception($"expected 1 tool event, got {toolEvents.Count}");
var ev0 = toolEvents[0];
if (ev0.Tool != "add" || ev0.Source != "native") throw new Exception($"{ev0.Tool}/{ev0.Source}");
// A real success: not an error, not pending.
if (ev0.IsError || ev0.Pending) throw new Exception("expected a clean success, not error/pending");
Console.WriteLine($"ok: tool={ev0.Tool} source={ev0.Source} ms>={ev0.Ms >= 0}");
sealed class Stub : IDisposable
{
readonly HttpListener _listener = new();
readonly CancellationTokenSource _cts = new();
public int Port { get; }
public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler)
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
_listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
_listener.Start();
_ = Task.Run(async () =>
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
try { handler(ctx); } catch { }
}
});
}
public static void Json(HttpListenerContext ctx, int status, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.OutputStream.Close();
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}

3. Full surface — the same events feed client.Metrics()’s Prometheus text

Section titled “3. Full surface — the same events feed client.Metrics()’s Prometheus text”
using System.Net;
using System.Text;
using Toolnexus;
using var stub = new Stub(ctx =>
{
Stub.Json(ctx, 200, """
{"id":"c1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":6,"completion_tokens":3,"total_tokens":9}}
""");
});
var events = new List<MetricEvent>();
var client = LlmClient.Create(new LlmClient.Options
{
BaseUrl = stub.BaseUrl,
Style = "openai",
Model = "gpt-4o-mini",
ApiKey = "test-key",
OnMetric = ev => events.Add(ev),
});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());
// Before any activity, Metrics() is still valid — just the HELP/TYPE header lines.
var before = client.Metrics();
if (!before.Contains("# HELP toolnexus_llm_requests_total")) throw new Exception("missing header before any calls");
var result = await client.RunAsync("say ok", tk);
var runEvent = events.Single(e => e.Event == "run");
if (runEvent.Model != "gpt-4o-mini") throw new Exception(runEvent.Model);
if (runEvent.TotalTokens != result.Usage.TotalTokens) throw new Exception($"{runEvent.TotalTokens} vs {result.Usage.TotalTokens}");
if (runEvent.Error != null) throw new Exception("expected a clean run, no Error");
// The exact same events also accumulated into the built-in Prometheus registry.
var after = client.Metrics();
if (!after.Contains("toolnexus_llm_requests_total{model=\"gpt-4o-mini\",status=\"ok\"} 1"))
throw new Exception(after);
if (!after.Contains("toolnexus_run_errors_total")) throw new Exception("missing run_errors header");
Console.WriteLine($"ok: {events.Count} events, run tokens={runEvent.TotalTokens}, Metrics() has {after.Split('\n').Length} lines");
sealed class Stub : IDisposable
{
readonly HttpListener _listener = new();
readonly CancellationTokenSource _cts = new();
public int Port { get; }
public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler)
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
_listener.Prefixes.Add($"http://127.0.0.1:{Port}/");
_listener.Start();
_ = Task.Run(async () =>
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
try { handler(ctx); } catch { }
}
});
}
public static void Json(HttpListenerContext ctx, int status, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = status;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.OutputStream.Close();
}
public void Dispose()
{
_cts.Cancel();
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}
Field Applies to What it is
Event all Discriminator: "llm", "tool", or "run".
Model llm, run Model name.
Status llm "ok" or "error".
PromptTokens / CompletionTokens llm Per-call token counts.
Tool / Source tool Tool name and its ITool.Source ("native", "mcp", …).
IsError tool Whether the tool call failed. Never true for a suspension — see Pending.
Pending tool §10: true iff this call suspended awaiting out-of-band resolution.
Turns / ToolCalls / TotalTokens run Totals for the whole run.
Error run Set once, if the run threw.
Ms llm, tool, run Duration in milliseconds.
  • LlmClient.Create — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
  • LlmClient.RunAsync — Send a prompt, let the loop call tools until the model stops, get a RunResult.
  • LlmClient.StreamAsync — The streaming loop: text deltas, tool-call events, and suspension events as they happen.
  • LlmClient.Hooks — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.