Telemetry
OpenTelemetry-native GenAI tracing, reuse your own TracerProvider and ship standard gen_ai.* spans to axonpush over OTLP.
AxonPush.Otel.Telemetry is the OpenTelemetry-native path. It reuses your
application’s own TracerProvider / ActivitySource, batches spans, and ships
them as real OTLP over HTTP to POST {BaseUrl}/v1/traces. The spans follow the
GenAI semantic conventions (gen_ai.operation.name, gen_ai.request.model,
gen_ai.usage.*), so they are portable to any OTLP backend, not just axonpush,
and there is no proprietary event format to translate.
Recommended over the legacy exporter
This is the recommended path for new code. The event-model
OpenTelemetry span exporter (AxonPushSpanExporter) still
works and is supported, but it converts each span into a proprietary app.span
event through the events API. This path uses the standard OpenTelemetry OTLP
exporter instead and is now the preferred way to trace GenAI calls.
Install
dotnet add package AxonPush.Otel
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocolTargets net8.0 and above.
Quickstart
Set AXONPUSH_BASE_URL, AXONPUSH_API_KEY and AXONPUSH_CHANNEL_ID in the
environment, then configure once at startup, start a GenAI activity per model
call, and record usage when the response returns.
using System.Diagnostics;
using AxonPush.Otel.Telemetry;
var handle = AxonPushTelemetry.ConfigureTelemetry(options =>
{
options.ServiceName = "my-agent";
options.Environment = "prod";
});
var source = new ActivitySource("axonpush");
using (var span = GenAi.StartSpan(
source,
operation: "chat",
requestModel: "gpt-4o",
system: "openai",
agentName: "research-agent"))
{
var response = await CallModelAsync();
GenAi.RecordResponse(
span,
responseModel: "gpt-4o",
inputTokens: 1200,
outputTokens: 350,
reasoningTokens: 64,
cacheReadTokens: 900,
cacheWriteTokens: 300);
GenAi.RecordContent(span, prompt: prompt, completion: response.Text, handle: handle);
}
handle.Flush(); // serverless: call at the end of each invocation
handle.Dispose(); // graceful shutdownConfigureTelemetry resolves BaseUrl, ApiKey and ChannelId from the
option first, then the matching AXONPUSH_* variable. The exporter posts to
{BaseUrl}/v1/traces with X-API-Key for auth and X-Axonpush-Channel for
routing, and subscribes the provider to the configured ActivitySource names
(default "axonpush").
Reuse the app’s TracerProvider
ConfigureTelemetry builds and owns a TracerProvider for you, which suits an
app that does not already run OpenTelemetry. When the app owns its own provider,
add the exporter to that builder instead with AddAxonPushTelemetry, the app
keeps ownership and its other instrumentation, and axonpush composes alongside
whatever else it exports to:
using OpenTelemetry;
using OpenTelemetry.Trace;
using AxonPush.Otel.Telemetry;
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("MyApp")
.AddAxonPushTelemetry(out var handle, options =>
{
options.ServiceName = "my-agent";
options.Environment = "prod";
})
.Build();The out TelemetryHandle handle carries the content-capture policy for
GenAi.RecordContent. A handle from AddAxonPushTelemetry does not own the
provider, so Dispose() on it is a no-op, dispose your own TracerProvider.
Do not double-instrument a call. If Semantic Kernel telemetry, the
AxonPushSpanExporter, or another instrumentation already produces a span for
the same model call, do not also wrap it with GenAi.StartSpan, you would emit
the operation twice. Pick one plane: native OTLP through this module, or the
events plane through the exporters.
Spanning a GenAI call
GenAi.StartSpan starts a Client activity named "{operation} {requestModel}"
(override with name). It sets gen_ai.operation.name and
gen_ai.request.model, and when supplied gen_ai.system /
gen_ai.provider.name from system, and gen_ai.agent.name from agentName.
It returns null when no listener is sampling the source; every GenAi helper
accepts a null activity and no-ops, so you can call them unguarded.
GenAi.RecordResponse records the response and usage attributes, including the
cache-token counts that map to the semconv
gen_ai.usage.cache_read_input_tokens and
gen_ai.usage.cache_write_input_tokens:
GenAi.RecordResponse(
span,
responseModel: "gpt-4o",
finishReasons: new[] { "stop" },
inputTokens: 1200,
outputTokens: 350,
reasoningTokens: 64,
cacheReadTokens: 900,
cacheWriteTokens: 300);Content capture and redaction
Prompts and completions are off by default. GenAi.RecordContent emits them as
span events (gen_ai.content.prompt / gen_ai.content.completion) rather
than attributes, so large payloads do not inflate the span, and it is gated by
the ContentCaptureMode policy:
| Mode | Behaviour |
|---|---|
ContentCaptureMode.MetadataOnly (default) | Content is dropped; only models and token counts are kept. |
ContentCaptureMode.Redacted | Short previews are kept, enough to recognise a run but not reconstruct it. |
ContentCaptureMode.Full | Content is kept verbatim. |
Credential-shaped keys are always stripped regardless of mode, and any keys you
name in RedactKeys are removed too. Set the policy on the handle:
var handle = AxonPushTelemetry.ConfigureTelemetry(options =>
{
options.ServiceName = "my-agent";
options.ContentCapture = ContentCaptureMode.Redacted;
options.RedactKeys = new[] { "email", "ssn" };
});
GenAi.RecordContent(span, prompt: prompt, completion: text, handle: handle);Or pass the policy per call with contentCapture: and redactKeys: instead of
handle:.
Flush on shutdown
Export is batched and non-blocking. Drain it on a graceful exit, and on
serverless, where the container is frozen between invocations, so the exit
flush is unreliable, call Flush() at the end of each invocation.
handle.Flush(timeoutMilliseconds: 2000); // returns true if it drained in time
handle.Dispose(); // flush and dispose the owned provider; idempotent