You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The generated client is a faithful transport for the Langfuse API, but it is a transport β it
mirrors the OpenAPI document one endpoint at a time. Anything an application actually wants to do
with it gets hand-rolled at every call site: pagination loops, cursor threading, existence checks,
"create if it isn't there", deleting a batch of things and finding out which ones worked.
I have built that layer for the Quarkus extension
(quarkiverse/quarkus-langfuse) and would like to
propose contributing a generalized version of it here. I am opening this as an issue rather than a
PR because the shape is worth agreeing before I restructure it.
What using the generated client looks like today
The code in this section is written against today's main β the Fern-generated client as
published in com.langfuse:langfuse-java:0.3.0. It assumes nothing about #36.
Every consumer writes both of these, and gets to decide for themselves whether an empty data or an
empty cursor means "done". There is no auto-pagination helper in the repo, and no test that
exercises a multi-page traversal.
The async client has the same shape, so the loops have to be rewritten again as a recursive CompletableFuture chain β there is no way to express "keep going until the cursor runs out"
without hand-rolling it.
What the higher-level layer looks like
In contrast to the section above, these examples assume #36. They are taken from the Quarkus
extension, which is built on a client with #36's structure β request parameter objects, the com.langfuse.api packages, and the SPI. That is what the layer sits on.
The method shapes themselves are not #36-specific β findAll() is findAll() either way β but the
implementation underneath calls the generated client the way #36 generates it, so the examples are
written that way rather than retrofitted onto today's Fern output.
List<PromptMeta> all = langfuse.prompts().findAll();
Lazily, short-circuiting so it stops fetching as soon as it has an answer:
Batch delete that accumulates rather than failing fast, and tells you what happened to each input:
varresult = langfuse.models().deleteByName("a", "b", "c");
result.deleted(); // Set<String>result.notFound(); // absence is not an errorresult.failed(); // Map<String, Throwable>
The same surface exists asynchronously. The Quarkus implementation returns Mutiny types, but nothing
about the design depends on that β upstream it would return CompletionStage, matching the async
client that already exists here:
CompletionStage<List<PromptMeta>> all = langfuse.prompts().findAll();
CompletionStage<DeletionResult> result = langfuse.models().deleteByName("a", "b", "c");
The two trees are kept deliberately independent rather than one wrapping the other, so neither pays
for the other's threading model.
To be concrete about what "framework-agnostic" costs, since the code already exists: of the 95
methods on the async tree, 89 return Uni<T> and map one-for-one onto CompletionStage<T> β
Mutiny even ships Uni.subscribeAsCompletionStage(), so the semantics are identical. The remaining 6 return Multi<T>: stream, streamAll, streamPages, streamBatches. Those are the lazy,
short-circuiting traversals, and CompletionStage has no streaming equivalent β collapsing them to CompletionStage<List<T>> would buffer the whole collection and lose the property that makes them
worth having.
The clean answer needs no new dependency: Mutiny's Multi<T> extends java.util.concurrent.Flow.Publisher<T>
already, so those six can return Flow.Publisher<T> (JDK 9+) and a Mutiny consumer wraps it back
with one call. The sync tree is unaffected β it returns java.util.stream.Stream, which is already
JDK-native.
So the port is: Uni β CompletionStage, Multi β Flow.Publisher, everything else unchanged.
It currently covers 17 domains, sync and async, and adds no dependency on any Quarkus API for
the parts that matter β the operations, the addressing types and the result types are plain Java
over the generated client.
The code, if that is more useful than the prose β four merged PRs, built incrementally:
eleven further domains, filtering, findById, parent scoping, time windows
None of it was designed speculatively
The surface came out of a public backlog rather than from my own idea of what a nice API looks like. quarkiverse/quarkus-langfuse#89
was opened as a running list of things that were awkward with the generated client β its opening
line is "The langfuse API is very tied to the underlying REST API. We'd like to add a 'common
functionality' layer that helps with higher level operations."
Half the comments on it are from another user, not me. What people actually asked for:
Asked for in #89
Became
"Finding things by name (evaluators, llm connections, etc) and handling pagination would be nice."
#92 β PR #98
"Batch deleting things would be nice, and not just by id. Deleting things by name."
#93 β PR #101
"LangfuseNotFoundException swallows context β the message is raw JSONβ¦ typed fields like getResourceName() would be much easier to work with."
#94
"It would also be nicer to work with a single dataset existence check, instead of skimming a list of datasets."
#95 β exists(...)
Those became issues #91β#97, all now closed. So the shape is not a guess at what consumers want β
it is what a handful of them asked for, one request at a time.
Rather than summarize it here, the relevant sections:
deprecated and /unstable/ endpoints, excluded by rule
The last two are the ones I would most want a reviewer to read, because they are the parts a generic
version would have to keep. Observations have no findById because the only by-id endpoint is the
deprecated v1 one; blob storage has findStatusById rather than findById because its {id} GET
answers a status object; experiments have neither because id and name are comma-separated list
criteria rather than unique keys. Each domain gets the shape its endpoint actually supports, and
every gap is documented with its reason rather than left to look like an oversight.
Beyond listing, it provides findByName / findById / exists, createIfAbsent versus a genuine
server-side upsert where one exists, page- and cursor-addressed traversal behind one vocabulary,
and batch delete with per-identifier outcomes.
Two design points that took the longest to get right, and that I think generalize:
Absence and failure are kept strictly apart. Only LangfuseNotFoundException is ever
recovered, and only by lookup methods. A 401 or a 500 must never surface as "not found" β that is
the single worst bug available in a layer like this.
A required query parameter is made unavoidable in the type. Langfuse requires fromStartTime
on the experiment listings, and a caller who omits it gets an HTTP 400. In the higher-level layer
you cannot reach the listing without supplying a bound: experiments() returns a time window, and
the listing operations hang off that rather than off the domain.
#31 / #36 are the foundation, and they are already running
This builds on #31 and its PR #36 ("Break api into 2 parts") β the API/implementation split, with com.langfuse.api packages, SPI-based client discovery and request parameter objects. It is not
hypothetical: I have replicated that design in the Quarkus extension as two local modules and have
been running it in production there:
The extension then implements that SPI β QuarkusLangfuseApiBuilderFactory, registered through META-INF/services β so the Quarkus integration plugs into the generated API exactly the way #36
says a framework-specific implementation should. That is the part I would most like you to take from
this: the SPI hook in #36 is not a speculative extension point, it has a real consumer and it works.
The operations layer described above sits on top of that stack. It could not have been built on the
current main layout β the request-object call style and the API/implementation split are what make
it worth writing once rather than once per framework.
On #31 you said the main requirement was that most of the package stay auto-generated, to keep
maintenance low as new routes arrive. That holds here in both directions. The API module in #36 is
generated from openapi.yml on every build and nothing in it is hand-edited. The layer proposed
here is hand-written by necessity β it encodes decisions a generator cannot make, such as which
endpoint is the current one when three exist β but it is additive and separate: a new route
appears in the generated API immediately, whether or not anyone curates it, and this layer never
blocks that.
langfuse-client/README.md in the Quarkus extension says, in full: "This will go away once #36 is merged & released." That is the plan β when #36
lands and ships, I drop both modules in quarkus-langfuse, or reduce them to whatever thin configuration remains once the
upstream artifacts are consumed directly. Carrying them locally in a downstream implementation is a stopgap, not a fork I want to
maintain.
So the sequencing is that split first, this second β I would not want to contribute this against the
current main layout and then rewrite it.
Spec-level issues this work surfaced
Building a client against every endpoint at once surfaces things that are invisible when you use one
at a time. These are defects in the API definition rather than in this repo, so I have filed them
upstream on langfuse/langfuse; they are listed here only because they shape what any generated
client β and therefore any layer above it β has to do:
Upstream issue
Why it matters to a client
#17625 β 585 of 638 error responses declare no schema (91.7%), including 400/401/403/404/405 on every operation
There is no generated error type at all, so every client hand-parses a body that has four distinct shapes in practice
langfuse/langfuse#17660 β 25 list responses use 5 pagination envelopes, 4 of them the same shape
CursorMeta, ExperimentsResponseMeta and ObservationsV2Meta are identical and share no supertype, so one cursor-traversal helper needs a per-domain adapter for the same getCursor()
langfuse/langfuse#17659 β metadata, config and modelParameters are typed as objects in some schemas and untyped in others
Object where a map belongs, on 34 properties across 24 schemas. Supersedes #38, which I filed here in June before realizing the spec is generated upstream
Tags drive generated class names, so the only supported score delete arrives as LegacyScoreV1Api.legacyScoreV1Delete(...)
Why regeneration is a consumer-facing problem
This repo's own README documents that regeneration needs manual repair afterwards: rewriting the
package across all files, re-applying a deprecated-endpoint prune, fixing Javadoc. That is a signal
worth taking seriously β if the maintainers must hand-patch each regeneration, consumers are exposed
to whatever changes between them.
Concretely, refreshing the vendored spec in the Quarkus extension silently removed three generated
model classes (SDKLogEvent, SDKLogEvent1, SDKLogBody). Nothing warned us; they simply stopped
being generated. Any consumer that had referenced them would have had a compile break with no
changelog entry, because generated sources are not committed and the spec diff is a single file.
The same will happen on a larger scale in November 2026, when the endpoints currently marked
deprecated (traces, sessions, /v2/scores, v1 observations, dataset runs) are removed from Langfuse
Cloud and disappear from the generated output.
A curated layer does not make that go away, but it does give it somewhere to land: the layer can
keep a stable method while the endpoint underneath it changes, deprecate deliberately, and refuse to
expose endpoints with a known removal date in the first place. Consumers program against the curated
surface; the generated one stays an escape hatch for anything not yet covered.
What I am proposing
A higher-level API lives in this repo β as its own module, so the generated artifact stays purely
generated and nothing here needs hand-editing. This is already here with Break api into 2 partsΒ #36.
I restructure the Quarkus extension's implementation of the higher abstraction layer to be
framework-agnostic and contribute it. This is not a green-field proposal β the code exists and
ships today, linked above. The Quarkus-specific parts are the CDI bean wiring and config; the
operations, addressing types and result types are plain Java over the generated client. The
port is mechanical: Uni β CompletionStage, Multi β Flow.Publisher, drop the CDI
annotations.
It is additive. Nothing about the generated client changes, and anyone happy with it today keeps
using it.
It follows Break api into 2 partsΒ #31 / Break api into 2 partsΒ #36 rather than competing with them. The layer is written against that
structure, so contributing it against today's layout would mean rewriting it once the split
lands. If the split is not going to happen, that is worth knowing before either of us spends
time on this.
Precedent: #13 / PR #28 added prompt compilation as hand-written convenience on top of generated
code, so this would not be the first such thing here.
Questions before I port it
Is this wanted at all? A polite "no, the generated client is the product" is a perfectly good
answer and saves us both time.
Where should it live β a module here, or a separate repo such as langfuse-java-sdk?
What is the minimum Java version? The current implementation uses sealed interfaces and
records, so it targets 17.
Sync and async, or sync only? The Quarkus implementation carries both trees. Upstream the
async tree would return CompletionStage, matching AsyncLangfuseClient as it already is β but
it doubles the surface, so it is worth deciding deliberately rather than by default. Personally I think async is important and would be a mistake to not have it.
There is nothing to prototype β the implementation is linked above, ships today, and is documented
in full. If the answers above are favorable and the split lands, the next step is a draft PR porting
it to the JDK types.
The generated client is a faithful transport for the Langfuse API, but it is a transport β it
mirrors the OpenAPI document one endpoint at a time. Anything an application actually wants to do
with it gets hand-rolled at every call site: pagination loops, cursor threading, existence checks,
"create if it isn't there", deleting a batch of things and finding out which ones worked.
I have built that layer for the Quarkus extension
(quarkiverse/quarkus-langfuse) and would like to
propose contributing a generalized version of it here. I am opening this as an issue rather than a
PR because the shape is worth agreeing before I restructure it.
What using the generated client looks like today
The code in this section is written against today's
mainβ the Fern-generated client aspublished in
com.langfuse:langfuse-java:0.3.0. It assumes nothing about #36.Fetching every prompt:
The cursor-addressed endpoints need a different loop again, and the cursor is an
Optional:Every consumer writes both of these, and gets to decide for themselves whether an empty
dataor anempty cursor means "done". There is no auto-pagination helper in the repo, and no test that
exercises a multi-page traversal.
The async client has the same shape, so the loops have to be rewritten again as a recursive
CompletableFuturechain β there is no way to express "keep going until the cursor runs out"without hand-rolling it.
What the higher-level layer looks like
In contrast to the section above, these examples assume #36. They are taken from the Quarkus
extension, which is built on a client with #36's structure β request parameter objects, the
com.langfuse.apipackages, and the SPI. That is what the layer sits on.The method shapes themselves are not #36-specific β
findAll()isfindAll()either way β but theimplementation underneath calls the generated client the way #36 generates it, so the examples are
written that way rather than retrofitted onto today's Fern output.
Lazily, short-circuiting so it stops fetching as soon as it has an answer:
Filtered, where the filter is a typed per-domain object rather than a bag of nullable strings:
Batch delete that accumulates rather than failing fast, and tells you what happened to each input:
The same surface exists asynchronously. The Quarkus implementation returns Mutiny types, but nothing
about the design depends on that β upstream it would return
CompletionStage, matching the asyncclient that already exists here:
The two trees are kept deliberately independent rather than one wrapping the other, so neither pays
for the other's threading model.
To be concrete about what "framework-agnostic" costs, since the code already exists: of the 95
methods on the async tree, 89 return
Uni<T>and map one-for-one ontoCompletionStage<T>βMutiny even ships
Uni.subscribeAsCompletionStage(), so the semantics are identical. The remaining6 return
Multi<T>:stream,streamAll,streamPages,streamBatches. Those are the lazy,short-circuiting traversals, and
CompletionStagehas no streaming equivalent β collapsing them toCompletionStage<List<T>>would buffer the whole collection and lose the property that makes themworth having.
The clean answer needs no new dependency: Mutiny's
Multi<T> extends java.util.concurrent.Flow.Publisher<T>already, so those six can return
Flow.Publisher<T>(JDK 9+) and a Mutiny consumer wraps it backwith one call. The sync tree is unaffected β it returns
java.util.stream.Stream, which is alreadyJDK-native.
So the port is:
UniβCompletionStage,MultiβFlow.Publisher, everything else unchanged.Evidence that it holds up
Full documentation: https://docs.quarkiverse.io/quarkus-langfuse/dev/api-operations.html
It currently covers 17 domains, sync and async, and adds no dependency on any Quarkus API for
the parts that matter β the operations, the addressing types and the result types are plain Java
over the generated client.
The code, if that is more useful than the prose β four merged PRs, built incrementally:
findById, parent scoping, time windowsNone of it was designed speculatively
The surface came out of a public backlog rather than from my own idea of what a nice API looks like.
quarkiverse/quarkus-langfuse#89
was opened as a running list of things that were awkward with the generated client β its opening
line is "The langfuse API is very tied to the underlying REST API. We'd like to add a 'common
functionality' layer that helps with higher level operations."
Half the comments on it are from another user, not me. What people actually asked for:
LangfuseNotFoundExceptionswallows context β the message is raw JSONβ¦ typed fields likegetResourceName()would be much easier to work with."exists(...)Those became issues #91β#97, all now closed. So the shape is not a guess at what consumers want β
it is what a handful of them asked for, one request at a time.
Rather than summarize it here, the relevant sections:
matching(XFilter), why it replaces rather than composes, and why filters share no supertypefindByIdversus the scanningfindByNameannotationQueues().items(queueId),evaluators().versions(evaluatorId)experiments()cannot be listed without a bound/unstable/endpoints, excluded by ruleThe last two are the ones I would most want a reviewer to read, because they are the parts a generic
version would have to keep. Observations have no
findByIdbecause the only by-id endpoint is thedeprecated v1 one; blob storage has
findStatusByIdrather thanfindByIdbecause its{id}GETanswers a status object; experiments have neither because
idandnameare comma-separated listcriteria rather than unique keys. Each domain gets the shape its endpoint actually supports, and
every gap is documented with its reason rather than left to look like an oversight.
Beyond listing, it provides
findByName/findById/exists,createIfAbsentversus a genuineserver-side
upsertwhere one exists, page- and cursor-addressed traversal behind one vocabulary,and batch delete with per-identifier outcomes.
Two design points that took the longest to get right, and that I think generalize:
LangfuseNotFoundExceptionis everrecovered, and only by lookup methods. A 401 or a 500 must never surface as "not found" β that is
the single worst bug available in a layer like this.
fromStartTimeon the experiment listings, and a caller who omits it gets an HTTP 400. In the higher-level layer
you cannot reach the listing without supplying a bound:
experiments()returns a time window, andthe listing operations hang off that rather than off the domain.
#31 / #36 are the foundation, and they are already running
This builds on #31 and its PR #36 ("Break api into 2 parts") β the API/implementation split, with
com.langfuse.apipackages, SPI-based client discovery and request parameter objects. It is nothypothetical: I have replicated that design in the Quarkus extension as two local modules and have
been running it in production there:
quarkus-langfuselangfuse-client,openapi-generator+ 9 custom Mustache templatescom.langfuse.apipackagesRelocateToSubpackagesscriptuseSingleRequestParameter=truecom.langfuse.api.spi.LangfuseApiBuilderFactory+ServiceLoadermodule-info.javalangfuse-testcontainersThe extension then implements that SPI β
QuarkusLangfuseApiBuilderFactory, registered throughMETA-INF/servicesβ so the Quarkus integration plugs into the generated API exactly the way #36says a framework-specific implementation should. That is the part I would most like you to take from
this: the SPI hook in #36 is not a speculative extension point, it has a real consumer and it works.
The operations layer described above sits on top of that stack. It could not have been built on the
current
mainlayout β the request-object call style and the API/implementation split are what makeit worth writing once rather than once per framework.
On #31 you said the main requirement was that most of the package stay auto-generated, to keep
maintenance low as new routes arrive. That holds here in both directions. The API module in #36 is
generated from
openapi.ymlon every build and nothing in it is hand-edited. The layer proposedhere is hand-written by necessity β it encodes decisions a generator cannot make, such as which
endpoint is the current one when three exist β but it is additive and separate: a new route
appears in the generated API immediately, whether or not anyone curates it, and this layer never
blocks that.
langfuse-client/README.mdin the Quarkus extension says, in full: "This will go away once#36 is merged & released." That is the plan β when #36
lands and ships, I drop both modules in
quarkus-langfuse, or reduce them to whatever thin configuration remains once theupstream artifacts are consumed directly. Carrying them locally in a downstream implementation is a stopgap, not a fork I want to
maintain.
So the sequencing is that split first, this second β I would not want to contribute this against the
current
mainlayout and then rewrite it.Spec-level issues this work surfaced
Building a client against every endpoint at once surfaces things that are invisible when you use one
at a time. These are defects in the API definition rather than in this repo, so I have filed them
upstream on
langfuse/langfuse; they are listed here only because they shape what any generatedclient β and therefore any layer above it β has to do:
CursorMeta,ExperimentsResponseMetaandObservationsV2Metaare identical and share no supertype, so one cursor-traversal helper needs a per-domain adapter for the samegetCursor()metadata,configandmodelParametersare typed as objects in some schemas and untyped in othersObjectwhere a map belongs, on 34 properties across 24 schemas. Supersedes #38, which I filed here in June before realizing the spec is generated upstreamLegacyScoreV1holds a non-deprecated endpointLegacyScoreV1Api.legacyScoreV1Delete(...)Why regeneration is a consumer-facing problem
This repo's own README documents that regeneration needs manual repair afterwards: rewriting the
package across all files, re-applying a deprecated-endpoint prune, fixing Javadoc. That is a signal
worth taking seriously β if the maintainers must hand-patch each regeneration, consumers are exposed
to whatever changes between them.
Concretely, refreshing the vendored spec in the Quarkus extension silently removed three generated
model classes (
SDKLogEvent,SDKLogEvent1,SDKLogBody). Nothing warned us; they simply stoppedbeing generated. Any consumer that had referenced them would have had a compile break with no
changelog entry, because generated sources are not committed and the spec diff is a single file.
The same will happen on a larger scale in November 2026, when the endpoints currently marked
deprecated (traces, sessions,
/v2/scores, v1 observations, dataset runs) are removed from LangfuseCloud and disappear from the generated output.
A curated layer does not make that go away, but it does give it somewhere to land: the layer can
keep a stable method while the endpoint underneath it changes, deprecate deliberately, and refuse to
expose endpoints with a known removal date in the first place. Consumers program against the curated
surface; the generated one stays an escape hatch for anything not yet covered.
What I am proposing
generated and nothing here needs hand-editing. This is already here with Break api into 2 partsΒ #36.
framework-agnostic and contribute it. This is not a green-field proposal β the code exists and
ships today, linked above. The Quarkus-specific parts are the CDI bean wiring and config; the
operations, addressing types and result types are plain Java over the generated client. The
port is mechanical:
UniβCompletionStage,MultiβFlow.Publisher, drop the CDIannotations.
using it.
structure, so contributing it against today's layout would mean rewriting it once the split
lands. If the split is not going to happen, that is worth knowing before either of us spends
time on this.
Precedent: #13 / PR #28 added prompt compilation as hand-written convenience on top of generated
code, so this would not be the first such thing here.
Questions before I port it
answer and saves us both time.
langfuse-java-sdk?records, so it targets 17.
async tree would return
CompletionStage, matchingAsyncLangfuseClientas it already is β butit doubles the surface, so it is worth deciding deliberately rather than by default. Personally I think async is important and would be a mistake to not have it.
There is nothing to prototype β the implementation is linked above, ships today, and is documented
in full. If the answers above are favorable and the split lands, the next step is a draft PR porting
it to the JDK types.