NAME
Langertha::Knarr::Tracing - Automatic Langfuse tracing per proxy request
VERSION
version 1.102
SYNOPSIS
use Langertha::Knarr::Tracing;
my $tracing = Langertha::Knarr::Tracing->new(config => $config);
my $trace_id = $tracing->start_trace(
model => 'gpt-5.6-terra',
engine => 'Langertha::Engine::OpenAI',
messages => \@messages,
params => \%params,
format => 'openai',
);
# ... handle request ...
$tracing->end_trace($trace_id,
output => $response_text,
model => 'gpt-5.6-terra',
usage => { input => 100, output => 50, total => 150 },
);
DESCRIPTION
Records every proxy request as a Langfuse trace with a nested generation. When tracing is not configured (no public and secret key), all methods are no-ops.
Two transports carry the trace to Langfuse, selected by "transport": ingestion (the default) posts events to Langfuse's /api/public/ingestion API, otel exports OpenTelemetry spans (see "OpenTelemetry transport"). Both record the same content through the same calls, in one fire-and-forget POST per request that gives up after "langfuse_timeout" in Langertha::Knarr::Config seconds (default 15).
Langfuse credentials are read from the config file's langfuse: section or from the LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_URL environment variables. The module strips surrounding quotes from environment variable values, which Docker --env-file sometimes adds literally.
Timing sources
Knarr has two request paths and they do not measure latency the same way. The generation's startTime always marks the moment "start_trace" ran; what differs is where endTime and completionStartTime come from.
Routed, non-streaming — a Langertha engine produced a Langertha::Response, so Langertha::Knarr::Handler::Tracing hands the engine-measured
timinghash to "end_trace".endTimebecomesstartTime + total_secondsandcompletionStartTimebecomesstartTime + ttft_seconds, both anchored to the high-resolution timestamp "start_trace" recorded. This is the only path with a real time-to-first-token, and the durations exclude the proxy's own formatting overhead.Routed, streaming — the decorator accumulates deltas and never sees a response object, so it measures time-to-first-token itself in the proxy: Langertha::Knarr::Handler::Tracing starts its clock before opening the upstream stream and stops at the first non-
undefdelta, then passes thatttft_secondsto "end_trace" astiming.completionStartTimeis therefore emitted, but — unlike the non-streaming path, where the engine measures — this figure is the proxy's own view and includes its dispatch overhead.endTimestays the wall-clock moment the stream was exhausted; a stream that yields no delta (empty or failed) produces notimingand nocompletionStartTime.Raw passthrough — bytes are piped 1:1 and never parsed, so no Langertha::Response exists at all.
endTimeis again the proxy's own wall clock at "end_trace", which includes network time to the upstream provider.
Callers that pass no timing therefore keep exactly the previous behaviour: proxy-measured endTime, no completionStartTime.
OpenTelemetry transport
With "transport" otel, each request goes out as one OTLP/HTTP request, JSON encoded, to /api/public/otel/v1/traces under "config"'s Langfuse URL, with the same Basic authentication and the x-langfuse-ingestion-version: 4 header (Langfuse's real-time ingestion path). A span must not be exported twice, so "start_trace" only records the start and "end_trace" builds and sends the finished trace, two spans under one random trace id:
a root span named after "trace_name" (kind
SERVER) withlangfuse.trace.name,langfuse.trace.tags(knarr),langfuse.trace.metadata.*(format,engine,model,params,passthrough_fallback), and the request messages and the output (or the error) aslangfuse.observation.input/langfuse.observation.output, which Langfuse shows as the trace's input and output;its child
proxy-request(kindCLIENT), alangfuse.observation.typegenerationwithlangfuse.observation.model.name, input and output,langfuse.observation.usage_details(the same buckets the ingestion path sends asusageDetails, cache reads and writes apart from the uncached input, see "end_trace"),langfuse.observation.completion_start_timeandlangfuse.observation.metadata.*(timing,response_id,configured_model,thinking,rate_limit,tool_calls). Astart_hiresgiven to "start_trace" is the start of both spans.
An error sets the status of both spans to ERROR with the message, plus langfuse.observation.level ERROR and langfuse.observation.status_message, so the trace itself shows as failed, not only its generation.
Span start and end are the instants the ingestion path reports (see "Timing sources"), in Unix nanoseconds. Structured values (messages, params, usage, metadata hashes) are JSON-encoded into string attributes; plain strings go as they are, as Langfuse's own SDKs send them.
config
The Langertha::Knarr::Config object. Required. Provides Langfuse credentials and trace_name.
trace_name
The Langfuse trace name applied to all traces. Resolved in priority order from: langfuse.trace_name in config, LANGFUSE_TRACE_NAME env var, KNARR_TRACE_NAME env var, or the default knarr-proxy.
transport
ingestion (default) or otel, from "langfuse_transport" in Langertha::Knarr::Config (langfuse.transport, else KNARR_LANGFUSE_TRANSPORT). An enabled tracer reads it, and "langfuse_timeout" in Langertha::Knarr::Config, when it is built, so an invalid value croaks at startup, not on the first request.
start_trace
my $trace_info = $tracing->start_trace(
model => $model_name,
engine => $engine_class,
messages => \@messages,
params => \%params,
format => 'openai',
);
Creates a new Langfuse trace and generation. Returns a $trace_info hashref that must be passed to "end_trace". Returns undef when tracing is disabled.
The returned hashref carries start_hires, the gettimeofday pair behind start_time. "end_trace" anchors engine-measured durations to it; see "Timing sources".
Two optional arguments:
start_hires-- agettimeofdaypair the trace starts at instead of now, for a trace opened after the request went out. The raw passthrough of a model that may still fall back to its engine opens its trace only once the upstream's status rules that out (see "raw_passthrough" in Langertha::Knarr).passthrough_fallback-- the upstream status that sent a raw passthrough request to its engine instead (401), recorded in the trace's metadata. Langertha::Knarr::Handler::Tracing passes it from the request.
end_trace
$tracing->end_trace($trace_info,
output => $response_text,
model => $model,
usage => { input => 100, output => 50, total => 150 },
timing => { ttft_seconds => 0.25, total_seconds => 1.5 },
response_id => 'chatcmpl-123',
);
# On error:
$tracing->end_trace($trace_info, error => "Something went wrong");
Closes the generation and trace started by "start_trace", then flushes the batch to Langfuse. Pass error to record a failed generation at level ERROR. Does nothing when $trace_info is undef (tracing was disabled at start).
Optional metadata carried off a Langertha::Knarr::Response, all skipped when absent:
timing— HashRef withttft_seconds/total_seconds. Drives the generation'sendTimeandcompletionStartTime(the Langfuse field for time-to-first-token) and is recorded verbatim in the metadata, so provider-native stage durations survive too. See "Timing sources".response_id— the provider's own response id, for correlating a Langfuse generation with the provider's logs.configured_model— the model name Knarr answers the client under when it differs from themodelthe backend reported (a routed model's configured name). The generation'smodelis the reported one; this keeps the configured one next to it.thinking— reasoning text the engine split offcontent. It is model output thatoutputno longer contains, so the trace is the only place it survives.rate_limit— a Langertha::RateLimit (or equivalent hashref). Flattened to its quota scalars; the raw header hash is not recorded.usage— a Langertha::Usage (the shape every routed response carries), a hashref in Langfuse's own keys (input/output/total), or a provider's usage hash (OpenAIprompt_tokens, Anthropicinput_tokens, Ollamaprompt_eval_count, ...), read through "from_hash" in Langertha::Usage. Whatever the input, the generation gets the counts in the only keys Langfuse stores:usageDetailsfor v3, which lets it overrideusage, andusageas{ input, output, total, unit => 'TOKENS' }for Langfuse v2, which drops the key it does not know.usageDetailsholds Langfuse's exclusive buckets, each priced on its own andtotaltheir sum:inputis the uncached input, prompt-cache reads go toinput_cached_tokensand cache writes toinput_cache_creation(each only when not zero), andoutput. Every token lands in exactly one bucket, whichever way the provider counts: Anthropic reportscache_read_input_tokens/cache_creation_input_tokensbesideinput_tokens, OpenAI'sprompt_tokens_details.cached_tokensis part ofprompt_tokensand is taken out ofinput. v2'susagehas no cache buckets, so itsinputis the whole input, cache included. A hashref in Langfuse's keys may carry the two cache buckets itself; its missingtotalis the sum of its buckets; a provider'stotalis always the sum. Without usage, with an empty hash, or with one that counts no tokens (an error body, a hash without any count key), neither key is sent, so Langfuse never records a zeroed usage. The raw passthrough passes the provider's usage hash it read off a copy of the upstream's answer (see "tracing" in Langertha::Knarr).tool_calls— the response's Langertha::ToolCall list. The trace is the detailed view, so it records the full tool calls —id,name, the completeargumentsandsynthetic— flattened to plain hashes. The JSONL request log keeps only a trimmed form (see "end_request" in Langertha::Knarr::RequestLog).
flush
$tracing->flush;
Sends all pending trace events to Langfuse in one POST and clears the internal buffer: an ingestion batch to /api/public/ingestion, or, with "transport" otel, an OTLP ExportTraceServiceRequest to /api/public/otel/v1/traces. Called automatically by "end_trace". Does nothing when tracing is disabled or the batch is empty.
Never throws: a batch that cannot be JSON-encoded is logged at error level and dropped, the same way an ingestion HTTP failure is. "end_trace" runs on the request's response path, so a tracing problem must not take the client's response down with it.
The POST is sent without waiting for it and gives up after "langfuse_timeout" in Langertha::Knarr::Config seconds (langfuse.timeout or KNARR_LANGFUSE_TIMEOUT, default 15, 0 for none): a Langfuse that accepts the connection and never answers is logged as a Langfuse flush error warning, and never holds a request.
SEE ALSO
Langertha::Knarr — Tracing is wired in automatically for all routes
Langertha::Knarr::Config — Provides Langfuse credentials
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-knarr/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.