Langfuse¶
Send Foundry agent telemetry and Microsoft.Extensions.AI.Evaluation scores to
Langfuse so agent runs and evals show up on the Langfuse
dashboard — with a single call.
Foundry already emits OpenTelemetry traces and metrics using the GenAI semantic
conventions (gen_ai.*), which Langfuse understands natively. The
NexusLabs.Foundry.Langfuse package adds the missing piece: an OTLP
exporter pointed at Langfuse, per-scenario trace grouping, and a bridge that turns
evaluator metrics into Langfuse scores. It supports both exporter-owning standalone
sessions and a complete non-owning facade for dependency-injection-based hosts.
Quick Start¶
Install the package and start a session from environment variables:
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
# Required unless Region is configured in code:
export LANGFUSE_HOST="http://localhost:3000"
using NexusLabs.Foundry.Evaluation;
using NexusLabs.Foundry.Langfuse;
// 1. Start export (no-ops cleanly when credentials are absent).
using var langfuse = LangfuseTelemetry.Start(LangfuseOptions.FromEnvironment());
// 2. One Langfuse trace per eval scenario.
using (var scenario = langfuse.BeginScenario(
name: "trip-planner: NYC -> Tokyo",
sessionId: runId,
tags: ["regression"]))
{
// Agent telemetry produced here nests under the scenario trace.
var run = await runner.RunAsync(config, hooks, cancellationToken);
var inputs = run.Diagnostics!.ToEvaluationInputs();
var result = await new EfficiencyEvaluator(tokenBudget: 200_000).EvaluateAsync(
inputs.Messages, inputs.ModelResponse, additionalContext: [new AgentRunDiagnosticsContext(run.Diagnostics!)]);
// 3. Evaluator metrics become Langfuse scores on this trace.
await result.RecordLangfuseScoresAsync(
scenario,
new LangfuseEvaluationScoreOptions
{
ScoreIdProvider = metric => $"{runId}:{metric.Name}",
},
cancellationToken);
}
var shutdown = langfuse.Shutdown(TimeSpan.FromSeconds(5));
var publication = langfuse.PublicationHealth.GetSnapshot();
Console.WriteLine($"traces={shutdown.Traces}, metrics={shutdown.Metrics}");
Console.WriteLine(
$"trace dropped={publication.TraceExport.Dropped}, " +
$"score failed={publication.ScoreUploads.Failed}, " +
$"retries={publication.Retries.Total}, drain={publication.Drain.Status}");
That is the entire integration. When LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY
are not set (or no Host/Region target is chosen), the session is disabled and
every call becomes a no-op, so the same code runs unchanged in credential-less CI.
Flush versus final shutdown¶
Flush(timeout) asks the active session to export telemetry buffered before the call and
keeps the session open. Use an explicit timeout when flushing before read-back operations.
Shutdown(timeout) is final: it shares one timeout budget across trace and metric providers,
releases every resource owned by the standalone session, and rejects new session operations.
It returns a LangfuseShutdownOutcome with separate trace and metric statuses. Those statuses
describe the local OpenTelemetry drain only—they do not guarantee durable Langfuse ingestion.
Dispose() performs the same final shutdown with LangfuseOptions.ShutdownTimeout, which
defaults to five seconds. Set Timeout.InfiniteTimeSpan only when an unbounded process-exit wait
is explicitly required.
Even shorter: evaluate and record in one call¶
EvaluateAndRecordAsync runs a set of evaluators and records every resulting metric
as a score, collapsing the per-test boilerplate:
using (var scenario = langfuse.BeginScenario("trip-planner", sessionId: runId))
{
var run = await runner.RunAsync(config, hooks, cancellationToken);
var inputs = run.Diagnostics!.ToEvaluationInputs();
await scenario.EvaluateAndRecordAsync(
evaluators: [new EfficiencyEvaluator(tokenBudget: 200_000), new IterationCoherenceEvaluator(maxIterations: 20)],
messages: inputs.Messages,
modelResponse: inputs.ModelResponse,
options: new LangfuseEvaluateAndRecordOptions(
chatConfiguration: null,
additionalContext: [new AgentRunDiagnosticsContext(run.Diagnostics!)],
scoreOptions: new LangfuseEvaluationScoreOptions
{
ScoreIdProvider = metric => $"{runId}:{metric.Name}",
}),
cancellationToken: cancellationToken);
}
Use the four-argument overload when chat configuration, additional context, score identity, and
cancellation all use their defaults. The explicit overload requires one
LangfuseEvaluateAndRecordOptions value plus an explicit cancellation token. The options constructor
snapshots additional context, and a null context sequence becomes an empty read-only collection.
Two runnable examples live under src/Examples/AgentFramework/:
LangfuseEvaluationApp— the full flow with no LLM or Langfuse credentials required (no-ops cleanly without keys).LangfuseConformanceApp— a small Langfuse-supported eval that reads the trace and scores back from a live Langfuse (local Docker by default) to prove ingestion. It also has no-server modes:resiliencyproves cancellation, context-safe experiment item callbacks, safe trace propagation, structured link failure, score-failure reporting, and bounded shutdown while Langfuse is unreachable.dependency-injectionprovesILangfuseClientexposes the complete evaluation surface through one host-owned telemetry pipeline without registering a standalone session.
What appears in Langfuse¶
| Foundry telemetry | Langfuse |
|---|---|
agent.chat / agent.chat.stream spans |
Generations with model + token usage |
agent.tool {name} spans |
Tool-call observations |
gen_ai.usage.* tags |
Token usage / cost on each generation |
| Scenario root span | The trace (named, with session id, tags, metadata) |
Evaluator metrics (via RecordLangfuseScoresAsync) |
Scores attached to the trace |
Scores¶
Langfuse does not ingest scores through OpenTelemetry span attributes — scores
are sent to the Langfuse Scores API. RecordLangfuseScoresAsync (and the
ILangfuseScenario.RecordEvaluationAsync / RecordScoreAsync methods) handle this
for you, keyed by the scenario's trace id. The mapping is:
| MEAI metric | Langfuse score type |
|---|---|
NumericMetric |
NUMERIC |
BooleanMetric |
BOOLEAN (1/0) |
StringMetric |
CATEGORICAL |
Each metric's Reason is sent as the score comment. Metrics whose value is unset
are skipped. Scores can be recorded while the scenario is still open — Langfuse
links them to the trace once it arrives.
Score names are sent verbatim by default (preserving the evaluator's authored metric
name). For cleaner dashboard filtering and grouping, enable
NormalizeScoreNames to send snake_case names (e.g. all_tool_calls_succeeded) —
this is the recommended shape unless you specifically want the authored names.
Each score and evaluation API exposes two overloads: one with only the required
arguments, and one with nullable options plus an explicit CancellationToken.
To customize either concern, call the full overload and pass null or
CancellationToken.None for the concern you are not customizing. There are no
token-only or options-only overloads.
Stable score IDs and safe retries¶
Supply LangfuseScoreOptions.Id when a score publication must be safely repeatable:
await scenario.RecordScoreAsync(
"correctness",
0.94,
new LangfuseScoreOptions
{
Id = $"{runId}:case-42:correctness",
Comment = "All required facts were present.",
},
cancellationToken);
For a complete EvaluationResult, provide one stable id per metric:
await scenario.RecordEvaluationAsync(
result,
new LangfuseEvaluationScoreOptions
{
ScoreIdProvider = metric => $"{runId}:case-42:{metric.Name}",
},
cancellationToken);
Langfuse uses the request-body id together with the score name and its server-assigned UTC date.
There is no Idempotency-Key header. Foundry automatically retries score requests only when Id
is present, always resending the complete unchanged payload. A retry that crosses a Langfuse UTC
date boundary can still create another score, so callers should treat a stable id as bounded to
one publication window rather than as a permanent global upsert key.
GET requests and identical dataset upserts are also retry-safe. Other POST operations—including experiment item links, comments, prompts, dataset items, score-config creation, and model creation—are never retried blindly.
Score-upload failures are non-fatal by default¶
A score is a dashboard write that happens after an eval has already produced its
verdict, so a transient Langfuse outage must not turn a green eval red. By default
(ScoreFailureMode.NonFatal) a failed upload increments
PublicationHealth.GetSnapshot().ScoreUploads.Failed and invokes ScoreErrorCallback
(wire it to your logger) but does not throw. Set
ScoreFailureMode.Strict if a missing score should hard-fail the caller.
Caller-requested cancellation is never converted into a non-fatal score or publication
failure. An OperationCanceledException propagates so a cancelled evaluation stops promptly.
var options = LangfuseOptions.FromEnvironment();
options.ScoreErrorCallback = e => logger.LogWarning(e.Exception, "Langfuse score {Name} not recorded", e.ScoreName);
Publication health¶
ILangfuseClient.PublicationHealth reports trace export, direct REST publication, retries, and
local drain separately:
LangfusePublicationHealthSnapshot health =
langfuse.PublicationHealth.GetSnapshot();
Console.WriteLine(health.TraceExport.LocallyEnqueued);
Console.WriteLine(health.TraceExport.Dropped);
Console.WriteLine(health.TraceExport.Acknowledged);
Console.WriteLine(health.TraceExport.Failed);
Console.WriteLine(health.ScoreUploads.Failed);
Console.WriteLine(health.ItemLinks.Failed);
Console.WriteLine(health.Retries.RateLimited);
Console.WriteLine(health.Drain.Status);
Console.WriteLine(health.Drain.Duration);
The stages are intentionally distinct:
LocallyObservedmeans the custom processor saw a recorded activity.LocallyEnqueuedmeans the bounded local queue accepted it.Droppedmeans the local queue rejected it because it was full or closing.Acknowledgedmeans the OTLP exporter returned success for the batch.Drain.Completedmeans the local provider removed the requested work before its timeout.
None of those values proves that Langfuse durably processed the event or made it queryable.
Use read-back through Langfuse's public API when a workflow requires end-to-end confirmation.
REST Succeeded counts likewise mean that Langfuse accepted the API request, not that asynchronous
materialization completed.
Span enrichment¶
When exporting, a span processor sets two things Langfuse cares about so they don't depend on implicit inference:
langfuse.observation.type=generationonagent.chat/agent.chat.streamspans andspanonagent.toolspans.langfuse.observation.usage_details(JSON) projected from Foundry'sgen_ai.usage.*tags, soinput,output,cache_read_input_tokens, andreasoning_tokensland reliably. When a span also carries MEAI'sgen_ai.usage.*attributes, the explicitlangfuse.observation.usage_detailscleanly replaces them — Langfuse does not sum the two, so there is no double-counting. (Langfuse derives cost from a model-price table; register prices for your model names if you want cost populated. Note that provider/SDK model names such as Copilot's will not match Langfuse's built-in table, socostDetailsstays empty until you add a custom model definition.)
Trace context is propagated through an allowlist
Foundry propagates the scenario name, tags, metadata, version, session id, and user id to in-process child spans so Langfuse can filter and aggregate observations reliably. Scenario context is not stored in W3C baggage, and arbitrary inherited OpenTelemetry baggage is not copied into exported span attributes. This prevents unrelated, sensitive, or high-cardinality application context from being sent to Langfuse or downstream services accidentally.
Experiments (datasets and runs)¶
The provider-neutral runner can materialize hosted Langfuse datasets through
LangfuseDatasetCaseSource<TCase>. Langfuse owns remote dataset/version semantics while the core
runner continues to own scheduling, retries, evaluation, and quality policy.
Hosted dataset case source¶
LangfuseDatasetSelection.Version is an optional UTC timestamp. Omit it for the latest dataset state
at query time, or set it to reproduce the item state at an explicit Langfuse dataset version:
using System.Text.Json;
using NexusLabs.Foundry.Evaluation.Experiments;
using NexusLabs.Foundry.Langfuse;
public sealed record TripPlannerCase(
JsonElement? Input,
JsonElement? ExpectedOutput,
JsonElement? Metadata,
string? SourceTraceId,
string? SourceObservationId);
var selection = new LangfuseDatasetSelection
{
Name = "trip-planner-evals",
Version = DateTimeOffset.Parse("2026-07-01T12:00:00Z"),
};
var source = new LangfuseDatasetCaseSource<TripPlannerCase>(
langfuse.Datasets,
selection,
item => new ExperimentCase<TripPlannerCase>
{
Id = item.Id,
Value = new TripPlannerCase(
item.Input,
item.ExpectedOutput,
item.Metadata,
item.SourceTraceId,
item.SourceObservationId),
TrialCount = 3,
Tags = ["hosted"],
});
var langfuseRun = langfuse.BeginExperimentRun(
selection.Name,
runId,
new LangfuseExperimentRunOptions
{
Description = "Trip planner regression suite",
DatasetVersion = selection.Version,
});
var langfuseScope =
langfuse.CreateExperimentItemScopeProvider<TripPlannerCase, TripPlannerOutput>(
langfuseRun,
new LangfuseExperimentItemScopeOptions<TripPlannerCase>
{
ScenarioNameFactory = _ => "evaluate-trip-planner-item",
FailureMode = ExperimentItemScopeFailureMode.BestEffort,
});
var langfuseSink =
langfuse.CreateExperimentResultSink<TripPlannerCase, TripPlannerOutput>(
langfuseRun,
new LangfuseExperimentResultSinkOptions<TripPlannerCase, TripPlannerOutput>
{
ItemScoreIdProvider = (item, metric) =>
$"{runId}:{item.Case.Id}:{item.TrialIndex}:{metric.Name}",
RunEvaluationScoreIdProvider = (evaluation, metric) =>
$"{runId}:{evaluation.Name}:{metric.Name}",
DecisionScore = new LangfuseExperimentDecisionScoreOptions
{
Name = "experiment_decision",
ScoreIdProvider = _ => $"{runId}:decision",
},
});
var definition = new ExperimentDefinition<TripPlannerCase, TripPlannerOutput>
{
Name = "trip-planner-regression",
CaseSource = source,
ItemScopes = [langfuseScope],
Sinks = [langfuseSink],
Task = RunTripPlannerAsync,
};
The source retrieves dataset metadata, follows every dataset-item page in provider order, excludes archived items, and validates the complete selection before the runner starts any trial. Duplicate item ids, changing or malformed pagination, inconsistent dataset identity, mapper duplicates, and empty hosted datasets fail the load. Disabled Langfuse also fails explicitly instead of producing an empty run.
The mapper must preserve item.Id as ExperimentCase<TCase>.Id. That provider-neutral case identity
is the binding used by the Langfuse per-trial scope to create a dataset-run-item link. Input, expected
output, metadata, source trace id, and source observation id remain available to the caller-owned
case value.
The resulting ExperimentSourceReference contains the hosted dataset name and id. Explicit version
timestamps are normalized to UTC; latest selections leave Version unset because the public
Langfuse API does not return a resolved version identifier.
For lower-level reads, ILangfuseDatasetClient also exposes validated ListDatasetsAsync,
ListDatasetItemsAsync, and fully materialized GetDatasetAsync operations.
Per-trial trace and hosted link scope¶
CreateExperimentItemScopeProvider<TCase,TOutput> binds the generic runner to an existing
ILangfuseExperimentRun. One scenario trace is created when a statistical trial enters its scope.
Every operational retry and the item evaluator reactivate that same trace, while the hosted
dataset-run-item link is attempted at most once. The same run instance retains authoritative
dataset-run identity and is reused by the result sink.
When a task or item evaluator needs direct scenario APIs, resolve the exact feature:
var scenario = context.Features.GetRequired<ILangfuseScenario>();
if (context.Case.Value.Input is { } input)
{
scenario.SetInput(input);
}
The item result uses the langfuse correlation namespace with:
trace.id;dataset.run.item.id;dataset.run.id.
Linked produces successful item publication. Failed and Inconsistent produce structured
publication failure without changing item quality in best-effort mode. NotSampled and Disabled
are NotAttempted. A required publication affects aggregate publication health, while
ExecutionPrerequisite independently selects strict link behavior and prevents the first attempt
when linking fails. Unsampled and disabled scopes never block execution.
The Langfuse scope returns only an ExperimentItemPublicationOperationResult containing status,
correlations, and an optional failure. The generic runner stamps the registered provider name and
requirement flag into the canonical ExperimentItemPublicationResult.
An explicit LangfuseExperimentRunOptions.DatasetVersion is normalized to UTC and submitted with
every item link. Use the same value as LangfuseDatasetSelection.Version so the comparison view
references the item state that the source loaded.
Hosted and local scope factories each expose two shapes only: a default overload without options and
an explicit overload that requires a non-null LangfuseExperimentItemScopeOptions<TCase>.
For local cases that are not hosted in Langfuse, use a trace-only scope:
Local recorded traces produce only trace.id; no fake dataset or dataset run is created. Scope
success means the trace lifecycle was created locally and does not claim durable OpenTelemetry
ingestion.
Canonical result publication¶
CreateExperimentResultSink<TCase,TOutput> projects each canonical item EvaluationResult to its
correlated trace, each successful run evaluator to the authoritative dataset run, and an optional
categorical ExperimentRunDecision score. The sink never reruns evaluators or policies.
Hosted and local result-sink factories likewise expose a default overload and an explicit overload
that requires non-null LangfuseExperimentResultSinkOptions<TCase,TOutput>.
Use contextual score-id callbacks when provider retry or rerun idempotency matters. Item callbacks
receive case/trial identity, and run callbacks receive the evaluator identity, so equal metric names
do not collide across trials or evaluators. Langfuse's score identity still includes the score name
and UTC date; see LangfuseScoreOptions for the provider constraint.
The sink maps direct score outcomes independently from quality:
- accepted and intentionally skipped score projections complete successfully;
- nonfatal API failures return a structured failed sink result;
- strict score mode throws into the generic sink pipeline, which isolates later sinks;
- missing or inconsistent hosted dataset-run identity fails configured run-score publication;
- disabled and entirely unattempted publication remain
NotAttempted; - local mode publishes item trace scores and skips dataset-run/decision targets without fabricating a run.
The Langfuse sink returns only an ExperimentSinkPublicationOperationResult; the generic runner
stamps the configured sink name and requirement flag into the canonical ExperimentSinkResult.
GetPublicationSnapshot() retains ordered item and run-evaluation score results, the optional
decision score result, and the hosted run's existing link/run-score snapshot. REST acceptance does
not prove durable trace ingestion.
For local cases, pair the local scope with a local sink:
var localScope =
langfuse.CreateLocalExperimentItemScopeProvider<MyCase, MyOutput>();
var localSink =
langfuse.CreateLocalExperimentResultSink<MyCase, MyOutput>();
The same definition shape works against the disabled facade. No credential branches are needed inside the task, evaluator, policy, or runner.
Run the converged example without credentials, with local trace publication, or with a hosted dataset:
dotnet run --project src/Examples/AgentFramework/LangfuseConformanceApp -- experiment-runner
dotnet run --project src/Examples/AgentFramework/LangfuseConformanceApp -- experiment-runner hosted my-dataset
The first command uses the coherent disabled facade when credentials are absent and automatically uses local trace publication when credentials are present.
The API below is retained as the low-level/manual escape hatch for ad hoc item loops and direct run scores. It is not the canonical bulk experiment workflow.
Low-level manual publication¶
Map each eval case to a Langfuse dataset item, then on every run link the trace it produces as a dataset run item. Langfuse's experiment-comparison view lines runs up side by side and shows how scores move across commits.
// Once, at suite startup: ensure the dataset and its items exist.
await langfuse.Datasets.EnsureDatasetAsync("trip-planner-evals");
await langfuse.Datasets.UpsertItemAsync(new LangfuseDatasetItem
{
DatasetName = "trip-planner-evals",
Id = "nyc-tokyo", // stable id → re-running upserts, never duplicates
Input = new { from = "NYC", to = "Tokyo" },
ExpectedOutput = "a 3-stop itinerary",
});
// Per run: name it after something comparable (a git SHA, a CI run id).
var run = langfuse.BeginExperimentRun(
"trip-planner-evals",
runName: gitSha,
options: new LangfuseExperimentRunOptions
{
Description = "Trip planner regression suite",
DatasetVersion = selection.Version,
Metadata = new
{
candidate = new { model = "gpt-5", promptVersion = 7 },
baseline = "main",
},
});
foreach (var item in items)
{
var itemResult = await run.RunItemAsync(
item.Id,
async (scenario, cancellationToken) =>
{
var evaluation = await RunAndEvaluate(item, cancellationToken);
await scenario.RecordEvaluationAsync(
evaluation,
new LangfuseEvaluationScoreOptions
{
ScoreIdProvider = metric => $"{gitSha}:{item.Id}:{metric.Name}",
},
cancellationToken);
return evaluation;
},
options: null,
cancellationToken: cancellationToken);
Console.WriteLine(
$"{item.Id}: {itemResult.Link.Status}, run {itemResult.Link.DatasetRunId}");
}
if (run.DatasetRunId is { } datasetRunId)
{
await run.RecordScoreAsync(
"average_correctness",
0.94,
new LangfuseScoreOptions { Id = $"{gitSha}:average_correctness" },
cancellationToken);
await run.RecordScoreAsync(
"passed",
true,
new LangfuseScoreOptions { Id = $"{gitSha}:passed" },
cancellationToken);
await run.RecordScoreAsync(
"verdict",
"acceptable",
new LangfuseScoreOptions { Id = $"{gitSha}:verdict" },
cancellationToken);
}
var publication = run.GetPublicationSnapshot();
Console.WriteLine(
$"REST publication: {publication.ApiPublicationStatus}; " +
$"trace ingestion verified: no");
RunItemAsync creates the scenario, links it, executes the callback while the scenario remains
the active Activity.Current across awaits, and disposes it before returning. Agent, chat, and
tool spans created inside the callback therefore nest beneath the item trace without callers
assigning Activity.Current or manually managing scenario lifetime.
The experiment-run methods use two explicit overloads: required arguments only, or required
arguments plus nullable options and an explicit cancellation token. To customize either concern,
call the full overload and pass null for default options or CancellationToken.None when
cancellation is not needed. There are no token-only or options-only overloads.
The dataset and its items must exist before the run links to them. Link failures use
LangfuseExperimentItemLinkFailureMode.BestEffort by default: the callback still runs,
itemResult.Link.Status is Failed, and details reach DiagnosticsCallback. Select strict behavior when
publication is a prerequisite:
var itemResult = await run.RunItemAsync(
item.Id,
async (scenario, token) =>
{
var evaluation = await RunAndEvaluate(item, token);
await scenario.RecordEvaluationAsync(
evaluation,
options: null,
token);
return evaluation;
},
new LangfuseExperimentItemOptions
{
LinkFailureMode = LangfuseExperimentItemLinkFailureMode.Strict,
},
cancellationToken);
Strict mode propagates LangfuseException and does not invoke the callback when an attempted link
fails. NotSampled and Disabled remain explicit statuses and still execute the callback.
Each successful item-link response supplies the authoritative Langfuse dataset-run id. The first
successful response resolves run.DatasetRunId; subsequent and parallel responses must agree.
Conflicting ids move run.IdentityStatus to Inconsistent, clear the aggregate DatasetRunId,
and make later run-level scores NotAttempted (or throw under strict score-failure mode). Every
item retains the id returned for its own remote link in itemResult.Link.DatasetRunId.
LangfuseExperimentRunOptions.Metadata is serialized once when the run is created and submitted
with every item link. This guarantees that whichever parallel request creates the remote run
receives the same metadata. run.Metadata exposes that frozen requested value; it does not claim
that an already-existing remote run was overwritten.
Run-level numeric, boolean, categorical, and MEAI evaluation scores target the resolved
DatasetRunId:
await run.RecordScoreAsync(
"average_accuracy",
0.94,
new LangfuseScoreOptions { Id = $"{runId}:average_accuracy" },
cancellationToken);
await run.RecordScoreAsync(
"passed",
true,
new LangfuseScoreOptions { Id = $"{runId}:passed" },
cancellationToken);
await run.RecordScoreAsync(
"verdict",
"acceptable",
new LangfuseScoreOptions { Id = $"{runId}:verdict" },
cancellationToken);
IReadOnlyList<LangfuseExperimentRunScoreResult> results =
await run.RecordEvaluationAsync(
aggregateEvaluation,
new LangfuseEvaluationScoreOptions
{
ScoreIdProvider = metric => $"{runId}:{metric.Name}",
},
cancellationToken);
The methods return Accepted, Failed, NotAttempted, Skipped, or Disabled. Accepted
means the Scores REST API accepted the request; it is not a durable read-back acknowledgement.
GetPublicationSnapshot() provides coherent item-link and run-score counts plus an aggregate
direct-API status. It deliberately does not claim that OpenTelemetry traces were ingested:
LangfuseExperimentRunPublicationSnapshot snapshot = run.GetPublicationSnapshot();
Console.WriteLine(snapshot.IdentityStatus);
Console.WriteLine(snapshot.ItemLinks.Linked);
Console.WriteLine(snapshot.RunScores.Accepted);
Console.WriteLine(snapshot.ApiPublicationStatus);
Score configs¶
By default, scores are sent untyped. Declaring a score config once gives a score a defined data type, numeric range, or category set, so the dashboard renders consistent ranges and colors and validates incoming values. Match the config name to the score name you record.
await langfuse.ScoreConfigs.EnsureScoreConfigAsync(new LangfuseScoreConfig
{
Name = "correctness",
DataType = LangfuseScoreDataType.Numeric,
MinValue = 0,
MaxValue = 1,
});
await langfuse.ScoreConfigs.EnsureScoreConfigAsync(new LangfuseScoreConfig
{
Name = "verdict",
DataType = LangfuseScoreDataType.Categorical,
Categories = [new("pass", 1), new("fail", 0)],
});
EnsureDatasetAsync uses Langfuse's name-keyed dataset upsert directly. Identical concurrent calls
are safe to repeat after throttling, transport failure, or transient 5xx responses. Concurrent
calls that supply different descriptions are provider-defined last-writer-wins updates, so use one
canonical dataset definition.
Every ILangfuseDatasetClient member uses two explicit overloads: required arguments only (using
CancellationToken.None and, for EnsureDatasetAsync/ListDatasetsAsync/ListDatasetItemsAsync,
no description or page 1 of 50), or the full set of required arguments plus an explicit
CancellationToken. To customize paging or supply a description, call the full overload; there are
no token-only, description-only, or custom-page-without-a-token overloads.
Langfuse does not enforce score-config name uniqueness and does not accept a caller-supplied config
id. Foundry therefore acquires LangfuseOptions.ResourceLockProvider, re-lists every page inside
the lock, and compares the complete schema before creating. The default
LangfuseInProcessResourceLockProvider prevents duplicates within one process. Applications with
multiple workers or hosts must provide a distributed ILangfuseResourceLockProvider (for example,
backed by Redis or a database advisory lock). An existing config with the same name but a different
or ambiguous schema throws instead of silently accepting drift.
Observation- and session-level scores¶
Beyond whole-trace scores, you can score a single observation (a specific generation or tool call) or a whole session (a multi-turn conversation spanning traces):
// Score one observation within a trace (host path: you hold the ids).
await scoreClient.RecordObservationScoreAsync(traceId, observationId, "tool_correct", true);
// Score a whole session.
await scoreClient.RecordSessionScoreAsync(sessionId, "resolved", 0.8);
// From an eval scenario that was started with a sessionId:
await scenario.RecordSessionScoreAsync("resolved", true);
Metrics read-back (CI quality gates)¶
Read aggregates back from Langfuse via the Metrics API — for example to fail a CI run if the
average eval score regressed. session.Metrics (or a DI-injected ILangfuseMetricsClient)
exposes a typed query plus a convenience for the common case:
// after recording scores and flushing (allow a few seconds for ingestion):
var avg = await langfuse.Metrics.GetScoreAverageAsync(
"correctness", fromTimestamp: runStart, toTimestamp: DateTimeOffset.UtcNow, environment: "ci");
if (avg is < 0.8)
throw new InvalidOperationException($"correctness regressed: {avg}");
For arbitrary aggregates, use QueryAsync with a LangfuseMetricsQuery (views observations,
scores-numeric, scores-categorical; measures, dimensions, filters). Result columns are named
{aggregation}_{measure} (e.g. avg_value), and result.GetScalar("avg", "value") reads the
single aggregate row of an ungrouped query.
Ingestion is asynchronous
Flush and allow a few seconds for ingestion before querying (this loop ingested in ~7s on a local instance). Metrics API availability can vary by deployment version — verify against your target instance.
Model pricing¶
Langfuse derives a generation's cost from a model-price table; models it does not recognise (mock or internal model names) come back with no cost. Register a price so cost populates:
await langfuse.Models.EnsureModelPriceAsync(new LangfuseModelPrice
{
ModelName = "my-internal-model",
MatchPattern = "(?i)^my-internal-model$", // regex matched against the generation's model
InputPrice = 0.000001, // USD per input token
OutputPrice = 0.000002, // USD per output token
});
Langfuse model creation also lacks a reliable caller idempotency key and duplicate-name failures can surface as different HTTP statuses. Foundry uses the same resource-lock contract, compares the complete existing definition, and reconciles ambiguous transport/5xx failures by re-listing before retrying. Use a distributed lock provider for multi-process initialization. Register prices before the generations are ingested so Langfuse computes cost at ingestion time.
Prompt linking¶
Link the generations in a scenario to a versioned prompt managed in Langfuse, so you can
analyze scores by prompt version (prompt-regression tracking). Call SetPrompt before
running the agent — Foundry stamps langfuse.observation.prompt.name / version on the
chat-completion (generation) spans; tool spans are unaffected:
using var scenario = langfuse.BeginScenario("trip-planner");
scenario.SetPrompt("trip-planner", version: 7); // link generations to a managed prompt + version
var result = await RunAndEvaluate(...);
The prompt must already exist in Langfuse prompt management for the link to resolve. Pass
version: null to link by name only.
Prompt management (fetching)¶
Fetch the prompt an eval runs against from Langfuse, so the prompt lives in Langfuse and the
generations link to the exact version used. session.Prompts (or a DI-injected
ILangfusePromptClient) fetches and creates managed prompts:
var prompt = await langfuse.Prompts.GetPromptAsync("trip-planner", label: "production");
using var scenario = langfuse.BeginScenario("trip-planner");
scenario.SetPrompt(prompt!); // auto-links to prompt.Name + prompt.Version
var result = await RunAndEvaluate(prompt!.Text, ...); // use the fetched text as instructions
GetPromptAsync(name, label?, version?) returns null when the prompt is absent (or Langfuse is
unconfigured) — version takes precedence over label, and with neither the production label is
used. CreateTextPromptAsync(name, text, labels?) creates a new version. Passing the fetched
LangfusePrompt to SetPrompt is the link-only feature applied automatically.
Trace context: environment, release, and more¶
Set a deployment environment (e.g. ci, staging, production) and a release
(e.g. a git SHA) once — they are propagated to every exported span, so Langfuse keeps CI
eval noise out of production dashboards and lets you compare metrics across releases:
var options = LangfuseOptions.FromEnvironment();
options.Environment = "ci";
options.Release = gitSha;
Per scenario you can also enrich the trace:
scenario.SetInput(item.Input); // trace-level input shown at the top of the trace
scenario.SetOutput(finalAnswer); // trace-level output (e.g. the agent's final answer)
scenario.SetVersion("prompt-v7"); // langfuse.version
scenario.SetTracePublic(); // shareable URL — handy for linking a failure in a PR
Comments¶
Attach context to a trace — a CI run URL, a git commit, the failing assertion message. Comments are a post-flush operation: unlike scores (which Langfuse can link to a trace that arrives later), Langfuse rejects a comment whose target trace does not yet exist. So add comments after the trace has been flushed and ingested, keyed by trace id:
// after langfuse.Flush() and the trace has been ingested:
await langfuse.AddTraceCommentAsync(traceId, $"CI run {ciUrl} — expected 3 stops, got 2");
Comments are non-fatal; a failure is reported through DiagnosticsCallback rather than
thrown.
Composing with MEAI OpenTelemetry¶
Foundry's diagnostics middleware and MEAI's OpenTelemetryChatClient /
UseOpenTelemetry() can both create spans for the same chat call. To get the
richest gen_ai spans without duplicates, enable MEAI's OpenTelemetry and set
Foundry's chat-completion activity mode to enrich the parent span:
.UsingAgentFramework(af => af
.ConfigureMetrics(o => o.ChatCompletionActivityMode = ChatCompletionActivityMode.EnrichParent)
.UsingDiagnostics())
See GenAI Token Metrics for how Foundry and MEAI share
the gen_ai.client.token.usage histogram.
Configuration¶
LangfuseOptions.FromEnvironment() reads LANGFUSE_PUBLIC_KEY,
LANGFUSE_SECRET_KEY, and LANGFUSE_HOST. All values can also be set in code.
| Option | Default | Description |
|---|---|---|
PublicKey / SecretKey |
(from env) | Langfuse API keys. Both required to export. |
Host |
(unset) | Base URL (e.g. http://localhost:3000). One of Host or Region is required. |
Region |
(unset) | Langfuse Cloud region: Eu, Us, Jp, Hipaa. Setting it is an explicit opt-in to cloud export. |
Enabled |
true |
Set false to force a no-op even with credentials. |
ServiceName |
foundry-agent |
OpenTelemetry service.name resource attribute. |
Environment |
(unset) | Deployment environment (e.g. ci, production), emitted as langfuse.environment on every span so Langfuse partitions the data. |
Release |
(unset) | Release identifier (e.g. a git SHA), emitted as langfuse.release for cross-release comparison. |
IncludeMetrics |
false |
Export Foundry's gen_ai metrics. Off by default — see note below. |
ScoreFailureMode |
NonFatal |
NonFatal records a failed score upload in publication health and invokes the callback without throwing; Strict throws. |
ScoreErrorCallback |
(none) | Invoked with a LangfuseScoreError when a score upload fails under NonFatal. |
NormalizeScoreNames |
false |
When true, score names are normalised to snake_case for consistent dashboard filtering. |
DiagnosticsCallback |
(none) | Receives library diagnostic messages (e.g. the "no export target" warning). Wire to your logger. |
SamplingRatio |
1.0 |
Head-based trace sampling ratio (eval workloads want 1.0). |
ShutdownTimeout |
5 seconds |
Total trace + metric timeout budget used by standalone-session disposal. |
ResourceLockProvider |
in-process | Coordinates score-config and model creation. Use a distributed implementation when multiple processes initialize one project. |
AgentActivitySourceName |
NexusLabs.Foundry.MicrosoftAgentFramework |
Foundry agent span source to export. |
GenAiMeterName |
Experimental.Microsoft.Extensions.AI |
Meter owning gen_ai.client.token.usage. |
AdditionalActivitySources / AdditionalMeters |
(empty) | Extra sources/meters to export. |
REST timeout and retry controls¶
LangfuseOptions.Http exposes a bounded subset of transport policy:
| Option | Default | Description |
|---|---|---|
RequestTimeout |
30 seconds |
Timeout for each individual REST attempt. |
MaxAttempts |
3 |
Total attempts for retry-safe operations; 1 disables retries. |
InitialRetryDelay |
200 ms |
Initial exponential delay. |
MaxRetryDelay |
5 seconds |
Maximum exponential or Retry-After delay. |
Foundry honors Retry-After for HTTP 429 and retries transport failures plus HTTP
500/502/503/504 only when the operation is provider-idempotent. Delays and request timeout are
caller-cancelable, and caller cancellation propagates with the caller's token.
Trace queue and batch controls¶
LangfuseOptions.TraceExport configures the instrumented local processor:
| Option | Default | Description |
|---|---|---|
MaxQueueSize |
2048 |
Maximum completed activities held locally before new activities are dropped. |
ScheduledDelay |
5 seconds |
Maximum wait before exporting a partial batch. |
MaxBatchSize |
512 |
Maximum activities in one OTLP request; cannot exceed the queue size. |
ExporterTimeout |
30 seconds |
OTLP HTTP request timeout. |
All values are validated when an enabled integration starts. Queue admission and exporter results
feed PublicationHealth; the counters do not claim durable backend ingestion.
Cloud export is opt-in (no silent egress)
Providing only API keys is not enough to export — you must also set an explicit target
(Host for self-hosted, or Region for Langfuse Cloud). This prevents accidentally sending
traces (which may include prompts, agent outputs, and customer data) to Langfuse Cloud. When
keys are present but no target is set, export is disabled and DiagnosticsCallback receives a
one-line explanation.
OTLP metrics are not ingested by Langfuse (as of v3.x)
Langfuse's OTLP metrics endpoint accepts requests (returns HTTP 200) but does not
ingest the data, and there is no metrics read API — exported metrics are silently
discarded. IncludeMetrics is therefore false by default. Token usage already rides
on the generation spans (see below), so you lose nothing. Enable it only when pointing
the exporter at a backend that ingests OTLP metrics.
If you customised Foundry's telemetry source names via ConfigureMetrics(...), set
the matching AgentActivitySourceName / AgentMeterName / GenAiMeterName so those
streams are exported.
ASP.NET Core and generic hosts¶
For applications that already call AddOpenTelemetry(), register Langfuse export on
the host pipeline instead of starting a standalone session:
The built-in hosted REST clients use a named IHttpClientFactory pipeline. Configure it before
AddFoundryLangfuse when the application needs a proxy, custom certificates, handlers, or connection
policy:
builder.Services
.AddHttpClient(LangfuseServiceCollectionExtensions.HttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => CreateLangfuseHandler());
builder.Services.AddFoundryLangfuse();
This wires the OTLP exporter into the host's tracer and meter providers so they
share the application lifecycle, and registers the complete non-owning
ILangfuseClient facade:
public sealed class MyEvalRunner(ILangfuseClient langfuse)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
using var scenario = langfuse.BeginScenario(
"support-agent: refund",
sessionId: "ci-run-42",
tags: ["regression"]);
var response = await RunAgentAsync(cancellationToken);
await scenario.RecordScoreAsync(
"resolved",
response.Resolved,
options: null,
cancellationToken);
var experiment = langfuse.BeginExperimentRun(
"support-agent-regression",
"commit-abc123",
new LangfuseExperimentRunOptions
{
Metadata = new { branch = "feature/refunds" },
});
var item = await experiment.RunItemAsync(
"refund-case",
async (scenario, token) =>
{
await scenario.RecordScoreAsync(
"reviewed",
value: true,
options: null,
token);
return scenario.TraceId;
},
options: null,
cancellationToken: cancellationToken);
if (item.TraceId is { } traceId)
{
await langfuse.Scores.RecordScoreAsync(
traceId,
"published",
value: true,
options: null,
cancellationToken);
}
Console.WriteLine(item.Link.Status);
Console.WriteLine(experiment.GetPublicationSnapshot().ApiPublicationStatus);
}
}
ILangfuseClient deliberately does not implement IDisposable and exposes no flush or
shutdown methods. The host owns the TracerProvider, optional MeterProvider, and shared
HTTP transport; disposing the host releases them in the correct order. No
ILangfuseSession is registered in dependency injection.
Every specialized interface (ILangfuseScoreClient, ILangfuseDatasetClient,
ILangfuseScoreConfigClient, ILangfuseMetricsClient, ILangfuseModelClient, and
ILangfusePromptClient) resolves to the exact instance exposed by the facade. When Langfuse
is not configured, the facade and all specialized interfaces are one coherent set of disabled
no-ops, so host code does not branch on credentials.
DI override ownership
A custom unkeyed ILangfuseClient must be registered as a singleton instance before
AddFoundryLangfuse; its specialized clients are then exposed as externally owned instance
aliases. Alternatively, individual specialized interfaces may be overridden as singletons
and are composed into Foundry's facade. Scoped and transient overrides are rejected because a
singleton facade cannot safely capture them. Keyed registrations are independent and remain
untouched. The built-in PublicationHealth snapshot covers Foundry-owned exporters and REST
clients; a custom specialized client owns the observability of its own publication path.
Run the no-server ownership check:
Langfuse Cloud vs self-hosted¶
- Cloud: leave
Hostunset and setRegionexplicitly (Eu,Us,Jp, orHipaa). - Self-hosted: set
Hostto your deployment, e.g.http://localhost:3000. The OpenTelemetry endpoint requires Langfusev3.22.0or newer.
One of Host or Region must be set — there is no default cloud target, so traces
are never sent to Langfuse Cloud unless you opt in.
Langfuse ingests OTLP over HTTP (HTTP/protobuf); gRPC is not supported, so the
exporter is always configured for HTTP.