axonpush
.NET SDK

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.OpenTelemetryProtocol

Targets 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 shutdown

ConfigureTelemetry 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:

ModeBehaviour
ContentCaptureMode.MetadataOnly (default)Content is dropped; only models and token counts are kept.
ContentCaptureMode.RedactedShort previews are kept, enough to recognise a run but not reconstruct it.
ContentCaptureMode.FullContent 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