Featured image of post GenAI Observability in .NET: Logging, Tracing, and Metrics with Langfuse

GenAI Observability in .NET: Logging, Tracing, and Metrics with Langfuse

Learn how to add full GenAI observability to .NET applications using Langfuse with OpenTelemetry, including traces, metrics, logs, and tool-calling spans.

When you build a .NET application that calls an LLM, the hard part is rarely the first API call. The hard part starts after you deploy. How many tokens did each user consume? Which prompts are failing? How long do chat completions take? Why did a tool-calling chain produce the wrong result for one user but not another?

Traditional observability tools give you generic HTTP spans and CPU metrics, but they don’t understand LLM-specific signals like token usage, model name, temperature, prompt text, tool calls, or finish reason. That’s where Langfuse comes in.

Langfuse is an open-source LLM engineering platform that provides observability, evaluation, prompt management, and analytics. It supports OpenTelemetry natively, which means you can instrument your .NET GenAI application once and get structured LLM traces, metrics, and logs in one dashboard.

In this post, I’ll show how to instrument a .NET Web API that uses Semantic Kernel for chat completions via DeepSeek (OpenAI-compatible API), with Langfuse as the observability backend. The sample uses:

  • Microsoft.SemanticKernel — orchestration layer for LLM calls and tool invocation
  • DeepSeek — chat model via OpenAI-compatible endpoint
  • LangfuseDotnet — OpenTelemetry exporter for Langfuse
  • OpenTelemetry — traces, metrics, and structured logs
  • Custom meters — LLM-specific metrics (token count, latency, request rate)

What Langfuse Gives Us for GenAI Workloads

Standard observability backends treat all spans equally. A chat completion request looks like any other POST /api/v1/chat span. You lose the GenAI-specific context that matters for debugging and optimization.

Langfuse bridges that gap. It follows OpenTelemetry GenAI semantic conventions, so the SDK knows how to structure LLM telemetry:

  • Traces — full trace of a chat request with nested spans for tool calls, retrievals, and generations
  • Generations — LLM-specific observations with model name, provider, temperature, max tokens, input/output messages, token counts, and finish reason
  • Metrics — request counts, latency histograms, per-model token usage
  • Logs — structured application logs correlated with traces via trace IDs

All of this works through standard OpenTelemetry exporters. The Langfuse exporter is just another OTLP destination that understands GenAI conventions.

Getting Started: Package Setup

We need these packages in the project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Semantic Kernel -->
<PackageReference Include="Microsoft.SemanticKernel" Version="1.35.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.35.0" />

<!-- Langfuse + OpenTelemetry -->
<PackageReference Include="zborek.LangfuseDotnet" Version="0.10.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />

The Langfuse SDK (zborek.Langfuse) bundles the OpenTelemetry exporter, GenAI semantic convention helpers, and the IOtelLangfuseTrace service for scoped tracing.

Semantic Kernel preview warnings are suppressed so the build stays clean:

1
<NoWarn>SKEXP0010,SKEXP0050,SKEXP0001,SKEXP0055</NoWarn>

Running Langfuse Locally with Docker Compose

Langfuse is an open-source platform, so you can run it locally instead of relying on a managed cloud instance. The sample repository includes a docker-compose.yml that starts everything needed for local development:

1
docker compose up -d

The compose file starts Postgres, ClickHouse, MinIO, Redis, the Langfuse worker, and the Langfuse web UI. The web UI is exposed on http://localhost:3001.

It also seeds a default organization, project, and user account:

The sample’s appsettings.json already points to the seeded local project, so no manual key configuration is needed. The compose file sets both LANGFUSE_INIT_ORG_ID and LANGFUSE_INIT_PROJECT_ID; without these IDs Langfuse skips the seeding step.

1
2
3
4
5
6
7
8
{
    "Langfuse": {
        "Url": "http://localhost:3001",
        "PublicKey": "pk-lf-local-public-key",
        "SecretKey": "sk-lf-local-secret-key",
        "OnlyGenAiActivities": false
    }
}

The application checks for the Langfuse keys at startup. If they are empty, the exporter is not registered and the API still runs normally — it just does not send traces to Langfuse. This is useful for local development without the compose stack.

No Aspire orchestration is required. The sample is a plain ASP.NET Core project, and the compose stack is self-contained.

Configuring Langfuse with OpenTelemetry

The wiring happens in Program.cs. We register OpenTelemetry with the Langfuse exporter, and then register the scoped trace service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using zborek.Langfuse.OpenTelemetry;

var builder = WebApplication.CreateBuilder(args);

var langfuseSection = builder.Configuration.GetSection("Langfuse");
var hasLangfuseKeys = !string.IsNullOrEmpty(langfuseSection["PublicKey"])
    && !string.IsNullOrEmpty(langfuseSection["SecretKey"]);

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(
            serviceName: "LangfuseGenAI",
            serviceVersion: "1.0.0",
            serviceInstanceId: Environment.MachineName
        )
    )
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSource("LangfuseGenAI.WeatherPlugin");

        if (hasLangfuseKeys)
            tracing.AddLangfuseExporter(langfuseSection);
    })
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation()
            .AddMeter("LangfuseGenAI.Meters");
    });

if (hasLangfuseKeys)
    builder.Services.AddLangfuseTracing();

The AddLangfuseExporter call reads these settings from appsettings.json:

1
2
3
4
5
6
7
8
{
    "Langfuse": {
        "Url": "http://localhost:3001",
        "PublicKey": "pk-...",
        "SecretKey": "sk-...",
        "OnlyGenAiActivities": false
    }
}

Setting OnlyGenAiActivities to false keeps infrastructure spans (HTTP client, ASP.NET Core, and our custom tool-call activity source) visible alongside GenAI spans. If you set it to true, Langfuse filters out non-GenAI spans, which hides the tool-call child observations.

Setting Up Semantic Kernel with DeepSeek

Semantic Kernel handles the chat completion service, the plugin registry, and the automatic tool-calling loop. The DeepSeek API is OpenAI-compatible, so we use the OpenAI connector with a custom endpoint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var apiKey = Environment.GetEnvironmentVariable("APP_API_KEY")
    ?? builder.Configuration["AI:ApiKey"]
    ?? throw new InvalidOperationException("AI API key is required.");

var endpoint = aiConfig["Endpoint"] ?? "https://api.deepseek.com";
var chatModel = aiConfig["ChatModel"] ?? "deepseek-chat";

var kernelBuilder = builder.Services.AddKernel();
kernelBuilder.AddOpenAIChatCompletion(
    modelId: chatModel,
    apiKey: apiKey,
    endpoint: new Uri(endpoint));

kernelBuilder.Plugins.AddFromType<WeatherPlugin>();

builder.Services.AddScoped<ChatService>();

The same pattern works for any OpenAI-compatible provider — just change the Endpoint URL and model name. Because Semantic Kernel owns the abstraction, switching providers is a one-line change.

System Architecture

Here is how the components fit together:

%%{init: {"theme":"base", "themeVariables": { "primaryColor":"#A855F7", "primaryTextColor":"#FFFFFF", "primaryBorderColor":"#7C3AED", "lineColor":"#6B7280", "secondaryColor":"#F59E0B", "secondaryTextColor":"#FFFFFF", "secondaryBorderColor":"#D97706", "tertiaryColor":"#06B6D4", "tertiaryTextColor":"#FFFFFF" }}}%% flowchart TD classDef client fill:#a855f7,stroke:#7e22ce,color:#fff,font-size:14px classDef service fill:#06b6d4,stroke:#0891b2,color:#fff,font-size:14px classDef ai fill:#f59e0b,stroke:#d97706,color:#fff,font-size:14px classDef obs fill:#10b981,stroke:#059669,color:#fff,font-size:14px classDef metric fill:#8b5cf6,stroke:#6d28d9,color:#fff,font-size:14px subgraph Client["🖥️ Client"] C1["HTTP Client"] end C1 --> API subgraph App["📦 .NET GenAI API"] API["ASP.NET Core \nMinimal API"] CS["ChatService"] SK["Semantic Kernel\n+ WeatherPlugin"] OTel["OpenTelemetry \nSDK"] Meters["Custom Meters \n📊 Histogram/Counter"] API --> CS CS --> SK CS --> OTel CS --> Meters Meters --> OTel end subgraph AI["🧠 AI Provider"] DS["DeepSeek API\ndeepseek-chat"] end SK --> DS subgraph Observability["🔭 Observability"] LF["Langfuse"] direction LR end OTel -->|"OTLP Traces (GenAI)"| LF class C1 client class API,CS,SK service class DS ai class LF,OTel obs class Meters metric

The flow is straightforward: HTTP requests hit the minimal API endpoints, which delegate to ChatService. That service calls DeepSeek through Semantic Kernel and emits OpenTelemetry spans, metrics, and logs. The Langfuse exporter picks up GenAI-specific spans and sends them to Langfuse.

Instrumenting Chat Completions with Langfuse

The ChatService integrates Langfuse tracing alongside Semantic Kernel calls. Here is the full pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
public sealed class WeatherPlugin
{
    private static readonly ActivitySource ActivitySource = new("LangfuseGenAI.WeatherPlugin");

    [KernelFunction("get_weather")]
    [Description("Get the current weather for a city")]
    public string GetWeather(string city)
    {
        using var activity = ActivitySource.StartActivity("get_weather", ActivityKind.Internal);
        activity?.SetTag("tool.name", "get_weather");
        activity?.SetTag("tool.city", city);

        var result = city switch
        {
            "Tokyo" => "Sunny, 22°C",
            "Paris" => "Rainy, 15°C",
            "New York" => "Cloudy, 18°C",
            "London" => "Foggy, 12°C",
            _ => "Sunny, 20°C"
        };

        activity?.SetTag("tool.weather", result);

        return result;
    }
}

public sealed class ChatService(
    Kernel kernel,
    ILogger<ChatService> logger,
    IServiceProvider serviceProvider)
{
    private static readonly Meter ChatMeter = new("LangfuseGenAI.Meters");
    private static readonly Counter<int> ChatRequestCounter =
        ChatMeter.CreateCounter<int>("genai.chat.requests", description: "Total chat requests");
    private static readonly Histogram<double> ChatLatency =
        ChatMeter.CreateHistogram<double>("genai.chat.latency_ms", description: "Chat request latency in ms");
    private static readonly Counter<int> TokenCounter =
        ChatMeter.CreateCounter<int>("genai.chat.tokens", description: "Total tokens used");

    public async Task<string> ProcessChatAsync(
        string userMessage,
        string? sessionId = null,
        string? userId = null,
        CancellationToken cancellationToken = default)
    {
        var sw = Stopwatch.StartNew();
        ChatRequestCounter.Add(1);

        serviceProvider.GetService<IOtelLangfuseTrace>()?.SetPublic(true);

        logger.LogInformation(
            "Processing chat request. SessionId: {SessionId}, UserId: {UserId}",
            sessionId, userId);

        try
        {
            var chatCompletion = kernel.GetRequiredService<IChatCompletionService>();
            var chatHistory = new ChatHistory();
            chatHistory.AddSystemMessage(
                "You are a weather assistant. For any weather question, you must call the get_weather tool for each requested city and use the tool results in your answer. Do not guess weather.");
            chatHistory.AddUserMessage(userMessage);

            var executionSettings = new OpenAIPromptExecutionSettings
            {
                Temperature = 0.7f,
                MaxTokens = 1000,
                ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
            };

            var result = await chatCompletion.GetChatMessageContentAsync(
                chatHistory,
                executionSettings: executionSettings,
                kernel: kernel,
                cancellationToken: cancellationToken);

            sw.Stop();
            ChatLatency.Record(sw.ElapsedMilliseconds);

            var outputTokens = result.Content?.Length / 4 ?? 0;
            TokenCounter.Add(outputTokens);

            logger.LogInformation(
                "Chat completed in {ElapsedMs}ms. Output tokens (est): {OutputTokens}",
                sw.ElapsedMilliseconds, outputTokens);

            return result.Content ?? string.Empty;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Chat request failed for session {SessionId}", sessionId);
            throw;
        }
    }
}

The pattern covers all three pillars of observability:

  • Traces — Semantic Kernel’s built-in instrumentation emits a trace with nested generation observations, and the custom ActivitySource in WeatherPlugin emits child spans for each tool call.
  • Metrics — custom counters track request rate and token usage. A histogram tracks latency distribution. These can be scraped by Prometheus and visualized in Grafana for operational dashboards.
  • Logs — structured logs with session IDs and user IDs make it easy to correlate log entries with specific traces.

Adding Tool Calling with Semantic Kernel

Most real-world GenAI applications need more than text generation — they need the model to query databases, call APIs, or fetch live data. Semantic Kernel handles this with plugins and ToolCallBehavior.

The pattern has three parts:

1. Define a plugin with KernelFunction

Use a public method decorated with [KernelFunction] and [Description] to expose a callable tool. The method’s parameters and return type are automatically described to the model:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public sealed class WeatherPlugin
{
    [KernelFunction("get_weather")]
    [Description("Get the current weather for a city")]
    public string GetWeather(string city)
    {
        return city switch
        {
            "Tokyo" => "Sunny, 22°C",
            "Paris" => "Rainy, 15°C",
            _ => "Sunny, 20°C"
        };
    }
}

2. Register the plugin in Program.cs

Add the plugin to the kernel:

1
2
var kernelBuilder = builder.Services.AddKernel();
kernelBuilder.Plugins.AddFromType<WeatherPlugin>();

3. Enable auto-invocation in execution settings

Tell Semantic Kernel to automatically call the tool when the model requests it:

1
2
3
4
5
6
var executionSettings = new OpenAIPromptExecutionSettings
{
    Temperature = 0.7f,
    MaxTokens = 1000,
    ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
};

When the model decides it needs weather data, the flow is:

  1. Model returns a get_weather function call request instead of a text response
  2. Semantic Kernel invokes the WeatherPlugin.GetWeather method
  3. The result is sent back to the model in a second round
  4. Model produces the final text response using the tool output
  5. The whole exchange appears as one trace with nested tool-call observations

Langfuse captures the tool call as a separate child observation. You can see the tool inputs (city name) and outputs (weather string) alongside the final model response — all correlated in one trace.

Tool Calling in the Langfuse Trace

This is the dashboard view readers should focus on: one root chat request, one or more generation spans, and get_weather child span(s) under the same trace.

Below is the Langfuse traces list. Each row is one request; the observation count tells you whether tool calls were captured as separate spans.

Langfuse traces list showing chat-completion traces with multiple observations

Opening the newest trace shows the full tree: the root POST /api/v1/chat/ span, the chat-completion span, the HTTP POST spans to DeepSeek, and the nested get_weather tool-call activity.

Langfuse trace detail showing root span, chat-completion, HTTP posts, and get_weather tool-call observations

The Observations view lists every individual span across all traces, making it easy to filter for tool calls or generations.

Langfuse observations list showing SPAN and GENERATION rows for chat requests

What You Get in the Langfuse Dashboard

After running the sample and sending a few chat requests, Langfuse shows:

Traces view: Each chat request appears as a trace with the full input/output, model metadata, token counts, latency, and nested tool-call observations. When weather tool calling is active, the trace shows the get_weather child span under the root request.

Observations view: Every span is listed independently. You can filter by type (SPAN, GENERATION), name, or time range to inspect tool calls, HTTP calls, or model generations.

Sessions view: By passing sessionId in the request, Langfuse groups all traces from a single user session. This helps debug multi-turn conversations and identify UX issues.

Langfuse sessions view grouping traces by session ID

Users view: Passing userId lets you see per-user activity, total events, and estimated cost over any time range.

Langfuse users view showing demo-user activity and event counts

Metrics: Custom metrics like request count, latency histograms, and token counters integrate with Prometheus for alerting. Set an alert when token consumption exceeds a threshold or when p95 latency goes above 5 seconds.

Connecting Logs to Traces

The structured logs emitted by ILogger<ChatService> contain SessionId and UserId properties. When combined with OpenTelemetry logging, each log record inherits the trace ID from the current activity context. This means you can jump from a Langfuse trace directly to the correlating log entries in Loki or Elasticsearch, even when the trace includes tool calls.

To enable OpenTelemetry logging, add the OTLP log exporter in Program.cs:

1
builder.Logging.AddOpenTelemetry(logging => logging.AddOtlpExporter());

With that in place, every LogInformation call is enriched with TraceId, SpanId, and resource attributes automatically.

Conclusion

LLM observability needs more than generic HTTP tracing. Langfuse gives .NET developers a purpose-built platform that understands GenAI semantics — model names, token counts, prompt/response pairs, temperature, finish reasons, and tool-call spans — all through standard OpenTelemetry exporters.

The integration with Semantic Kernel means you don’t need to choose between clean abstractions and deep observability. You get both: the kernel handles chat completions and the tool-calling loop, while OpenTelemetry and Langfuse capture the full execution graph in the dashboard.

You can find the sample code in this repository:

Reference

Built with Hugo
Theme Stack designed by Jimmy