langfuse / langfuse/langfuse-java
Proposal: a higher-level operations API over the generated client
Nessuno ha ancora preso questa issue.
- Lingua principale
- Java
- Stelle
- 73
- Fork
- 14
- Merge medio
- 13h 54m
- PR unite (30g)
- 3
Descrizione
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.
Fetching every prompt:
var all = new ArrayList<PromptMeta>();
var page = 1;
PromptMetaListResponse response;
do {
response = client.prompts().list(ListPromptsMetaRequest.builder()
.page(page)
.limit(50)
.build());
all.addAll(response.getData());
page++;
} while (page <= response.getMeta().getTotalPages());
The cursor-addressed endpoints need a different loop again, and the cursor is an Optional:
var all = new ArrayList<Map<String, Object>>();
Optional<String> cursor = Optional.empty();
do {
var response = client.observationsV2().getMany(GetObservationsV2Request.builder()
.limit(50)
.cursor(cursor)
.build());
all.addAll(response.getData());
cursor = response.getMeta().getCursor();
} while (cursor.isPresent());
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:
langfuse.prompts().streamAll()
.filter(prompt -> prompt.getTags().contains("production"))
.findFirst();
Filtered, where the filter is a typed per-domain object rather than a bag of nullable strings:
var filter = CommentFilter.builder()
.objectType(CommentObjectType.TRACE)
.objectId(traceId)
.build();
langfuse.comments().matching(filter).findAll();
Batch delete that accumulates rather than failing fast, and tells you what happened to each input:
var result = langfuse.models().deleteByName("a", "b", "c");
result.deleted(); // Set<String>
result.notFound(); // absence is not an error
result.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.
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:
| PR | |
|---|---|
| #98 | the layer itself: pagination and streaming, lookup, the independent sync/async trees |
| #100 | evaluators and evaluation rules, the first cursor-addressed domains |
| #101 | batch and name-based delete, with per-identifier outcomes |
| #109 | 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:
| Available Operations | all 17 domains, their addressing model, and their entry point |
| Filtering | matching(XFilter), why it replaces rather than composes, and why filters share no supertype |
| Direct Lookup by Id | findById versus the scanning findByName |
| Parent-Scoped Sub-Collections | annotationQueues().items(queueId), evaluators().versions(evaluatorId) |
| Time-Windowed Collections | why experiments() cannot be listed without a bound |
| Deletion Outcomes | per-identifier results, and absence not being an error |
| Deleting by Name | name → id resolution, and prompts as the server-side exception |
| Why the Surface Is Asymmetric | every absent operation, with the endpoint reason it is absent |
| What This Layer Deliberately Does Not Cover | 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
LangfuseNotFoundExceptionis 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:
| #36 introduces | Replicated in quarkus-langfuse |
|---|---|
| generated API module from the OpenAPI spec | langfuse-client, openapi-generator + 9 custom Mustache templates |
com.langfuse.api packages |
same, via the same RelocateToSubpackages script |
| request parameter objects | useSingleRequestParameter=true |
| SPI-based client discovery | com.langfuse.api.spi.LangfuseApiBuilderFactory + ServiceLoader |
| JPMS module | module-info.java |
| testcontainers module | langfuse-testcontainers |
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
https://github.com/langfuse/langfuse-java/pull/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 |
langfuse/langfuse#17661 — LegacyScoreV1 holds a non-deprecated endpoint |
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 #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 #31 / #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 returnCompletionStage, matchingAsyncLangfuseClientas 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.
Guida per i contributori
Nessuna guida per i contributori indicizzata per questo repository
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Direzione di ricerca
Leggi le sezioni della issue “Available Operations”, “Why the Surface Is Asymmetric” e “What This Layer Deliberately Does Not Cover”, quindi confronta l’implementazione Quarkus esistente nelle PR #98, #100, #101 e #109. Esamina la foundation issue #36 e la PR #36 prima di proporre la forma dell’API, le operazioni supportate, i tipi di ritorno sincroni/asincroni e le esclusioni; il lavoro è completato quando i maintainer concordano sull’ambito e sul design.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- java
- Ambito
- api, backend-api-design
- Tipo di issue
- Funzionalità
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Stato di attività
- Attiva
- Chiarezza
- Abbastanza chiara
- Idoneità per principianti
- 30/100