Higher-Level API Operations
The generated LangfuseApi client mirrors the Langfuse REST API one endpoint at a time. For common tasks - looking things up by id or by name, checking whether they exist, ensuring resources are created idempotently at startup, restricting a collection to the items you care about, deleting them in batches, and walking paginated collections without hand-rolling page arithmetic - the extension provides a curated, higher-level layer in io.quarkiverse.langfuse.api.
Three subpackages hold the vocabularies every domain shares: io.quarkiverse.langfuse.api.paging (Page, PageSelection, PagedResult), io.quarkiverse.langfuse.api.cursor (Cursor, CursorSelection, CursorResult) and io.quarkiverse.langfuse.api.deletion (DeletionResult, DeletionOutcome). Per-domain filter types stay beside their domain interface in io.quarkiverse.langfuse.api: unlike a page or a cursor, a CommentFilter means nothing away from comments.
Anything not covered by this layer remains one call away through api(), which returns the underlying LangfuseApi.
Dependency and Injection
The operations layer is included automatically with the quarkus-langfuse dependency. No extra dependencies are required.
Two injection styles are supported:
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkiverse.langfuse.api.AsyncLangfuseOperations;
import io.quarkiverse.langfuse.api.LangfuseOperations;
@ApplicationScoped
public class TelemetryService {
// Synchronous facade, with langfuse.async() available
@Inject
LangfuseOperations langfuse;
// Standalone Mutiny-only bean (no blocking facade involved)
@Inject
AsyncLangfuseOperations asyncLangfuse;
}
AsyncLangfuseOperations is a standalone CDI bean, not merely a child of LangfuseOperations. Applications that never block can inject AsyncLangfuseOperations directly and ignore the synchronous tree entirely. Both routes resolve to the same underlying instance.
Available Operations
Langfuse uses two different pagination models depending on the resource, and one collection it does not paginate at all. The layer reflects those distinctions rather than hiding them behind an artificial abstraction:
| Domain | Addressing | Method | Description |
|---|---|---|---|
Models |
Page |
|
Query, paginate, create and delete model definitions |
Datasets |
Page |
|
Query, paginate and create datasets |
Dataset Items |
Page |
|
Query, filter, paginate, create and delete dataset items |
LLM Connections |
Page |
|
Query, paginate, configure and delete LLM provider connections |
Score Configs |
Page |
|
Query, paginate and create score configurations |
Prompts |
Page |
|
Query, paginate, create and delete prompts |
Annotation Queues |
Page |
|
Query, paginate and create annotation queues, and reach their items |
Annotation Queue Items |
Page, parent-scoped |
|
Query, paginate, create, update and delete the items of one queue |
Comments |
Page |
|
Query, filter, paginate and create comments |
Evaluation Rules |
Cursor |
|
Query, paginate via cursor, create and delete evaluation rules |
Evaluators |
Cursor |
|
Query, paginate via cursor, create and delete evaluators, and reach their version history |
Evaluator Versions |
Cursor, parent-scoped |
|
Walk the stored version history of one evaluator |
Scores |
Cursor |
|
Query, filter, paginate via cursor, create and delete scores |
Observations |
Cursor |
|
Query, filter and paginate observations via cursor |
Experiments |
Cursor, time-windowed |
|
Query, filter and paginate experiments within a time window |
Experiment Items |
Cursor, time-windowed |
|
Query, filter and paginate experiment items within a time window |
Blob Storage Integrations |
Unpaginated |
|
List, inspect status, upsert and delete blob storage integrations |
Every domain above is also reachable on the asynchronous tree under the same name - langfuse.async().scores(), or asyncLangfuse.scores() - returning Uni and Multi in place of values and streams.
The surface is deliberately not uniform: a domain declares the operations its endpoint actually supports, and no more. Why the Surface Is Asymmetric explains each case.
Datasets and score configs have no delete operations. This is a limitation of the Langfuse v4 API rather than an omission here: it exposes no delete endpoint for either resource. (DELETE /api/public/v2/datasets/{name}/runs/{runName} deletes a dataset run, which this layer does not model.)
|
Page-Addressed Operations
Models, datasets, dataset items, LLM connections, score configs, prompts, annotation queues, annotation queue items and comments are page-addressed (page and limit query parameters). Not every domain declares every row below - see Why the Surface Is Asymmetric:
| Synchronous | Asynchronous (Mutiny) | Description |
|---|---|---|
|
|
Direct lookup by id: a single request whatever the size of the collection, unlike |
|
|
Exact-match lookup. Emits |
|
|
Whether an item with that exact name exists. |
|
|
Fetches all items across all pages using the configured default page size. |
|
|
Fetches all items on the selected pages as a list. |
|
|
Lazily walks all items across all pages. |
|
|
Lazily walks items on the selected pages. |
|
|
Lazily walks pages, exposing totals and page metadata. |
|
|
Fetches a single page with its server-reported totals. |
|
|
Returns the existing item or creates it (non-atomic). |
|
|
Deletes one or more items by id, reporting an outcome per identifier. |
|
|
Resolves each name to its id, then deletes. See Deleting by Name - prompts are the exception, and delete by name server-side. |
findById is available only where Langfuse exposes a GET-by-id endpoint returning the resource. Among the page-addressed domains that means models, score configs, annotation queues, annotation queue items, comments and dataset items. Datasets are keyed by name (GET /api/public/v2/datasets/{datasetName}), LLM connections have no GET-by-id at all, and prompts resolve by name server-side, so none of the three declares it.
|
LLM connections are keyed by provider rather than name, so their operations are named findByProvider(String), exists(String) and deleteByProvider(String) (also available in varargs and Collection forms). In addition, LLM connections provide an atomic upsert(UpsertLlmConnectionRequest) instead of createIfAbsent. Deleting an LLM connection also causes Langfuse to pause any evaluators that depend on it.
|
Prompts list and look up as different types. PromptOperations walks PromptMeta - a name with its versions, labels and tags, but no prompt content - while findByName(String) fetches a Prompt, the polymorphic chat-or-text model. That asymmetry is the API’s. Prompts also resolve by name server-side, so findByName, exists and deleteByName each cost a single request rather than a paginated scan.
|
Cursor-Addressed Operations
Evaluation rules, evaluators, evaluator versions, scores, observations, experiments and experiment items use opaque server cursors rather than page numbers. Because Langfuse reports only a cursor and no total page count or item count, cursor operations do not fabricate totals or ordinal page indexes. As with the page-addressed domains, not every row below is declared by every domain:
| Synchronous | Asynchronous (Mutiny) | Description |
|---|---|---|
|
|
Direct lookup by id: a single request whatever the size of the collection, unlike |
|
|
Exact-match lookup. Emits |
|
|
Whether an item with that exact name exists. |
|
|
Fetches all items across all batches using the configured default batch size. |
|
|
Fetches all items in the selected batches as a list. |
|
|
Lazily walks all items across all batches. |
|
|
Lazily walks items in the selected batches. |
|
|
Lazily walks batches, exposing the next cursor. |
|
|
Fetches a single batch by cursor. |
|
|
Returns the existing item or creates it (non-atomic). |
|
|
Deletes one or more items by id, reporting an outcome per identifier. |
|
|
Resolves each name to its id, then deletes. |
| Deleting an evaluator removes the evaluator and all of its stored versions, and drops the evaluation-rule assignments that reference it. Scores it has already produced are preserved. Deleting an evaluation rule is narrower than it appears: it removes the live-ingestion rule only, leaving associated evaluators and previously produced scores in place. |
Unpaginated Operations
Blob storage integrations are the one collection Langfuse does not paginate: GET /api/public/blob-storage-integrations answers the whole list in a single response, with no cursor, no page count and no request object at all. BlobStorageIntegrationOperations therefore declares findAll() alone - no find(selection), no streamPages, no findBatch - because there is nothing for a selection to select.
| Synchronous | Asynchronous (Mutiny) | Description |
|---|---|---|
|
|
The whole list, in one request. |
|
|
The status of one integration - not the integration itself. See Why the Surface Is Asymmetric. |
|
|
Atomic server-side create-or-replace. |
|
|
Deletes one or more integrations by id, in the usual three arities. |
Filtering
Several Langfuse list endpoints accept query criteria. Where they do, the domain exposes a single matching(XFilter) method returning its own type, so a filtered collection is just another view of that domain and every operation above keeps working on it:
import io.quarkiverse.langfuse.api.CommentFilter;
var filter = CommentFilter.builder()
.objectType(CommentObjectType.TRACE)
.objectId("trace-1")
.build();
langfuse.comments().findAll(); // the whole collection
langfuse.comments().matching(filter).findAll(); // every comment on that trace
langfuse.comments().matching(filter).streamPages(PageSelection.all());
langfuse.async().comments().matching(filter).streamAll();
The domains carrying a filter are comments (CommentFilter), dataset items (DatasetItemFilter), scores (ScoreFilter), observations (ObservationFilter), experiments (ExperimentFilter) and experiment items (ExperimentItemFilter).
matching replaces the filter in force; it does not compose with it. .matching(a).matching(b) is filtered by b alone. Composition would have to choose a meaning for combining two filters - AND across all fields, override per field, or merge - and no choice is obviously right, so the layer makes none.
|
On a filtered view, findAll() means every item of this view, not every item in the remote collection - the same reading as stream().filter(…).count(), where the receiver scopes the operation. The filter lives in the receiver rather than in an argument, which is why findAll() stays zero-argument and there is no findAll(filter) claiming whole-collection semantics while taking criteria.
Building a Filter
Each filter is an immutable sealed interface with a fluent builder, a none() instance matching everything, and a toBuilder() for deriving a variant:
var numeric = ScoreFilter.builder()
.traceId("trace-1")
.dataType(ScoreDataType.NUMERIC)
.valueMin(0.5d)
.build();
var tighter = numeric.toBuilder()
.valueMax(0.9d)
.build();
Filters are per domain and share no supertype. A score filter offers twenty criteria, a comment filter three, and the two overlap barely at all; there is no common Filter type, and none is intended. Accessors return Optional, so an unset criterion is visibly unset rather than encoded as null.
Langfuse enforces combination rules server-side, and the filters do not pre-empt them. A rejected combination - a score valueMin without a NUMERIC dataType, a comment objectId without an objectType - surfaces as a LangfuseApiException carrying HTTP 400 when the listing runs, not as an IllegalArgumentException from the builder. The filter reports what you asked for; the server remains the authority on what it accepts.
|
Direct Lookup by Id
findById(String) is a single request whatever the size of the collection, because Langfuse resolves the id itself. Contrast findByName, which - on every domain except prompts - walks the collection until it finds a match, and walks all of it to prove a name absent.
langfuse.models().findById("model-id"); // one GET
langfuse.models().findByName("gpt-4o"); // a scan, short-circuited on a hit
Prefer findById wherever the id is already known.
|
It is declared by models, score configs, evaluation rules, evaluators, annotation queues, annotation queue items, comments and dataset items - every domain whose GET-by-id endpoint exists, is not deprecated, and returns the resource itself.
Parent-Scoped Sub-Collections
Two collections live under a parent in the Langfuse URL space, and are reached through a sub-accessor on the parent domain rather than by passing the parent id to every operation:
langfuse.annotationQueues().items(queueId).findAll();
langfuse.annotationQueues().items(queueId).create(request);
langfuse.evaluators().versions(evaluatorId).streamAll();
The view captures the parent, so no operation on it takes a queue or evaluator id. The parent is validated when the sub-accessor is called, so a blank id fails at items("") rather than later at findAll() - including on the asynchronous tree, where it is thrown from the call itself rather than emitted as a failed Uni.
Experiment items look parent-scoped but are not. Langfuse takes experimentId and experimentName as query criteria on a top-level collection, so they belong on ExperimentItemFilter and experiment items are reached from langfuse.experimentItems(), not from an experiment.
|
Time-Windowed Collections
The experiments and experiment-items endpoints make fromStartTime a required query parameter, so that the query stays fast on large projects. A zero-argument findAll() could not satisfy that, and would be a guaranteed HTTP 400.
Rather than document that hazard and leave it reachable, both accessors return a small gateway - ExperimentTimeWindow and ExperimentItemTimeWindow - that exposes only the two ways of supplying the bound. The collection operations exist only on the far side of one of them, so the unsatisfiable call cannot be written:
import java.time.OffsetDateTime;
var from = OffsetDateTime.now().minusDays(7);
langfuse.experiments().since(from).findAll();
langfuse.experiments().between(from, OffsetDateTime.now()).streamAll();
langfuse.experiments().since(from).matching(filter).findAll();
langfuse.experimentItems().since(from).matching(itemFilter).findAll();
since(from) leaves the upper bound open; between(from, to) closes it. The bound lives in the gateway and not in the filter, so there is never a conflict between the two with no defined winner.
Updating
update exists on exactly one domain: langfuse.annotationQueues().items(queueId).update(itemId, request).
Annotation queue items are the one place where the workflow is the state transition - moving an item to completed is the point of a queue - and Langfuse’s PATCH endpoint returns the updated entity. Three other domains have PATCH endpoints and do not get update from this: a domain earns an operation because its endpoint and its use case warrant one, never for symmetry with a sibling.
Pagination
Page Addressing
A Page is a 1-based coordinate. A PageSelection describes the intent of a traversal:
import io.quarkiverse.langfuse.api.paging.Page;
import io.quarkiverse.langfuse.api.paging.PageSelection;
// Coordinates
Page.of(2, 75); // page 2, 75 items per page
Page.ofSize(75); // page 1, 75 items per page
// Selections
PageSelection.all(); // every page, using default page size
PageSelection.all(75); // every page, 75 items per page
PageSelection.from(Page.of(2, 75)); // page 2 onward
PageSelection.only(Page.of(2, 75)); // page 2 only
PageSelection.range(2, 4, 75); // pages 2 and 3 (end exclusive)
PageSelection.rangeClosed(2, 4, 75); // pages 2, 3 and 4 (end inclusive)
Page indexes are 1-based, matching Langfuse’s REST API and UI. This differs from io.quarkus.panache.common.Page, which is 0-based. Because indexes are 1-based, range(1, 4, 50) selects three pages (1, 2, 3), not four.
|
Cursor Addressing
A Cursor is an opaque token returned by Langfuse, never parsed by the extension:
import io.quarkiverse.langfuse.api.cursor.Cursor;
import io.quarkiverse.langfuse.api.cursor.CursorSelection;
Cursor.first(75); // start of the collection, 75 items per request
Cursor.at("opaque-token", 75); // resume from a previous response's cursor
CursorSelection.all(); // every batch, using default batch size
CursorSelection.all(75); // every batch, 75 items per request
CursorSelection.from(cursor); // from cursor onward
CursorSelection.only(cursor); // that single batch only
CursorSelection.first(3, 75); // first 3 batches
Laziness and Short-Circuiting
Both Pagination (synchronous Stream) and AsyncPagination (Mutiny Multi) are lazy end-to-end:
-
No HTTP request is issued until the stream is consumed or the
Multiis subscribed to. -
Each page or batch is requested only when the previous one has been exhausted.
-
Short-circuiting terminal operations (
findFirst(),anyMatch(),limit(), Mutinyselect().first()) stop the traversal immediately; subsequent pages are never requested. -
An empty selection (e.g.,
range(2, 2, 50)) performs zero HTTP requests.
Error Handling
Absence and failure are strictly separated:
-
findById,findByName,findByProviderandfindStatusByIdreturnOptional.empty()(sync) or emitnull(async) only when the resource genuinely does not exist (404 from the server, or not found in the listing). -
existsreturnsfalseonly when the resource genuinely does not exist. -
All other failures - rejected credentials (
LangfuseAuthenticationException), refused actions (LangfuseAuthorizationException), transport errors, 5xx server errors - propagate as exceptions (all of themLangfuseApiExceptionsubtypes). They are never swallowed or logged as warnings. -
findAll(),find(),findPage(), andfindBatch()propagate 404 errors, because a 404 on a collection endpoint indicates a misconfigured URL or project rather than an empty collection (Langfuse returns 200 with an empty array for an empty collection).
Delete operations apply the same separation, but report it as data instead of throwing. An identifier matching nothing yields a NotFound outcome; every other failure yields a Failed outcome carrying the cause. Only a genuine not-found is treated as absence, so a 401 or a timeout can never be misread as a missing resource. Malformed input is the exception to this: a null or blank identifier is a programming error and throws IllegalArgumentException before any request is issued - including on the asynchronous tree, where it is thrown from the call itself rather than emitted as a failed Uni.
The whole set of identifiers is validated before the first request, not one at a time as the batch proceeds. A blank identifier in the tenth position therefore rejects the call without having deleted the first nine, so a batch that fails validation leaves nothing behind and is safe to retry in full once the input is corrected.
Exception Hierarchy
Every failed request raises a LangfuseApiException, or one of its subtypes when the status identifies a specific, separately actionable problem:
RuntimeException
└── LangfuseApiException (com.langfuse.api)
│ getStatusCode() - the HTTP status
│ getServerMessage() - the server's own words
│ getErrorBody() - the response body, typed
├── LangfuseAuthenticationException 401
├── LangfuseAuthorizationException 403
└── LangfuseNotFoundException 404
The three subtypes live in io.quarkiverse.langfuse.client. Catching LangfuseApiException handles every failure, so code that does not care about the distinction never needs to name the subtypes.
| Status | Exception | Meaning |
|---|---|---|
|
|
The credentials were rejected - no identity was established. Check |
|
|
The credentials were accepted, but the action is not permitted. Replacing the keys only helps if the new ones carry different permissions - review the key’s scope, role, or project. Some endpoints are gated behind a paid plan and answer |
|
|
The resource was not found. This is the only failure the operations layer treats as absence rather than as an error. |
other |
|
Malformed or rejected request - for example |
|
|
Server-side failure. |
The status is always available through getStatusCode(), and the server’s explanation through getServerMessage(), so the unmapped cases remain fully diagnosable without a dedicated type:
try {
var models = langfuse.models().findAll();
}
catch (LangfuseAuthenticationException e) {
// Wrong or missing API keys - the application is misconfigured
}
catch (LangfuseApiException e) {
// Anything else: rate limiting, a server error, a refused action
Log.warnf("Langfuse call failed with status %d: %s", e.getStatusCode(), e.getServerMessage());
}
A 404 does not always mean the resource is missing. Langfuse also answers 404 for some refusals - deleting a Langfuse-managed model reports No model with this id found. Note: You cannot delete built-in models… even though the model exists. "You may not delete this" and "this does not exist" are therefore indistinguishable by status code on those endpoints. getServerMessage() tells you which of the two happened; the status code alone does not.
|
Reading the Error Response
getMessage() carries the whole raw response body behind a Langfuse API error (404): ` prefix, which is exactly what you want in a stack trace and exactly what you do not want in a log line. Two accessors on `LangfuseApiException give that body as data instead, so no caller has to parse JSON out of an exception message. Both live on the base type, so they are reachable from the catch-all catch (LangfuseApiException e) block and are available for the statuses that have no dedicated subtype - 400, 409, 429, 5xx.
| Accessor | Use it when |
|---|---|
|
You want the server’s own sentence - for a log line, a UI notification, or a wrapped exception. This is the common path and involves no new types. |
|
You need more than the sentence: the machine-readable code, or the body exactly as it arrived. |
The Common Path: getServerMessage()
try {
var dataset = langfuse.datasets().createIfAbsent(request);
}
catch (LangfuseApiException e) {
// "Dataset name must not be empty" - not {"message":"Dataset name must not be empty"}
Log.warnf("Langfuse rejected the call (%d): %s", e.getStatusCode(), e.getServerMessage());
}
When the failure carried no response body at all - a transport error that never reached the server, or an error response with an empty body - getServerMessage() falls back to getMessage() rather than answering an empty string. It is therefore always worth logging, whatever went wrong.
getMessage() itself is unchanged and still carries the raw body. Nothing that reads it needs to be touched; the accessors are purely additive.
The Full Body: getErrorBody()
getErrorBody() answers a com.langfuse.api.LangfuseErrorBody and never returns null - a failure that carried no body answers an empty one. The type is a sealed interface with three accessors:
| Accessor | Meaning |
|---|---|
|
The server’s explanation. Never |
|
The machine-readable error code, when the server sent one. Usually empty - see below. |
|
The response body exactly as it arrived, before any interpretation. The escape hatch for a shape this type does not model. Never |
try {
var evaluators = langfuse.evaluators().findAll();
}
catch (LangfuseApiException e) {
var body = e.getErrorBody();
Log.warnf("Langfuse call failed (%d): %s [code=%s]",
e.getStatusCode(), body.message(), body.code().orElse("none"));
// code() is empty on most endpoints, so it can only ever refine the handling,
// never carry it - the status code stays the primary signal
var retryable = body.code()
.filter("rate_limited"::equals)
.isPresent();
}
code() is only populated by the evaluators and evaluation-rules endpoints, which are the only ones that declare a code in their error schema. The great majority of Langfuse endpoints declare their error response as an empty schema and send no code at all, so code() is empty far more often than not. Branch on getStatusCode(), and treat a present code() as a bonus.
|
Bodies That Cannot Be Understood
Not every error response is a Langfuse error object. An intervening proxy or load balancer may answer with an HTML error page, a gateway may send an empty body, and a misrouted request may return JSON that is not an object at all. Any of these yields an opaque body: message() answers the raw text rather than an empty string, code() is empty, and rawBody() holds the original bytes as text.
The practical consequence is that getServerMessage() and getErrorBody().message() are always safe to call and always worth logging. They degrade from "the server’s sentence" to "whatever text arrived" to "the exception message", but they never degrade to null and never to nothing.
Consuming Asynchronous Absence
Because Uni emits null when a resource is absent, use Mutiny’s null-handling operators when absent values must be handled explicitly:
// Throw an exception if missing
langfuse.async().models().findByName("gpt-4o")
.onItem().ifNull().failWith(() -> new IllegalStateException("gpt-4o is not registered"))
.map(Model::getId);
// Supply a fallback value
langfuse.async().models().findByName("gpt-4o")
.onItem().ifNotNull().transform(Model::getId)
.onItem().ifNull().continueWith("default-id");
Deletion Outcomes
Delete operations never fail fast. Every identifier is attempted regardless of what happened to the others, and each is reported separately, so one failure neither hides the successes nor prevents the remaining work. The result is a DeletionResult, holding one DeletionOutcome per distinct identifier:
var result = langfuse.models().deleteByName("gpt-4o", "retired-model", "never-existed");
result.deleted(); // Set<String> - identifiers whose resources were deleted
result.notFound(); // Set<String> - identifiers that matched nothing
result.failed(); // Map<String, Throwable> - identifiers whose deletes could not be completed
result.hasFailures(); // boolean - true if any outcome is a failure
result.size(); // int - number of distinct identifiers
result.outcome("gpt-4o"); // Optional<DeletionOutcome>
result.outcomes(); // Map<String, DeletionOutcome>
Results are keyed by the identifier you supplied, not by position - for deleteByName that is the name, not the resolved id. Identifiers are deduplicated before any request is made, so passing the same identifier twice yields one outcome and one request, and size() may be smaller than the number of identifiers passed.
| Outcome ordering is not part of the contract. Look outcomes up by identifier rather than relying on iteration order. |
DeletionOutcome is a sealed interface with exactly three branches, all nested inside it:
| Branch | Meaning |
|---|---|
|
The resource existed and was deleted. |
|
No resource matched the identifier. An expected outcome, not a failure - deleting something already absent is the normal result of an idempotent teardown. |
|
The delete could not be completed. The only branch that carries a |
Only Failed carries a cause, so a cause cannot be read off a success. Because the hierarchy is sealed and exhaustive, an application compiled against Java 21 or later can switch over it with no default branch, and adding a branch would become a compile error at every such call site:
import io.quarkiverse.langfuse.api.deletion.DeletionOutcome.Deleted;
import io.quarkiverse.langfuse.api.deletion.DeletionOutcome.Failed;
import io.quarkiverse.langfuse.api.deletion.DeletionOutcome.NotFound;
// Requires the consuming application to target Java 21+
var message = switch (outcome) {
case Deleted d -> "deleted %s".formatted(d.identifier());
case NotFound n -> "absent %s".formatted(n.identifier());
case Failed f -> "failed %s: %s".formatted(f.identifier(), f.cause().getMessage());
};
The branches are nested types, so case DeletionOutcome.Deleted d → is equally valid if you prefer qualification to an import.
Switch patterns are a Java 21 feature. On Java 17 an instanceof chain is the equivalent:
String message;
if (outcome instanceof Failed f) {
message = "failed %s: %s".formatted(f.identifier(), f.cause().getMessage());
} else if (outcome instanceof NotFound) {
message = "absent %s".formatted(outcome.identifier());
} else {
message = "deleted %s".formatted(outcome.identifier());
}
Deleting by Name
On models, evaluation rules, evaluators and LLM connections, Langfuse deletes only by id, so deleteByName (and deleteByProvider) resolves each name to its id first, exactly as findByName does. A name that matches stops the scan early, but proving a name is absent has no early exit and costs a full collection traversal. Deleting many absent names is therefore markedly more expensive than deleting the same number of ids.
Prefer deleteById wherever ids are already known. Reserve deleteByName for cases where the name is genuinely the only handle you have.
|
Prompts are different, and cost nothing extra. Langfuse keys the prompt delete endpoint on the name itself, so langfuse.prompts().deleteByName(…) issues one request per name with no resolution scan at all, and an absent name costs no more than a present one. The traversal cost above does not apply to prompts.
A prompt delete removes every version of the named prompt. Langfuse scopes the deletion by an optional label or version and this layer supplies neither, so the whole prompt goes. Use api() directly to delete a single label or version.
|
Write Semantics
Two verbs exist for "make sure this is there", and the difference between them is not cosmetic:
-
upsertis a single atomic server-side operation. Langfuse creates or replaces in one request; concurrent callers cannot both win. Only LLM connections and blob storage integrations have such an endpoint, and so only they declareupsert. -
createIfAbsentis find-then-create, and is not atomic. Two callers may both observe absence and both create. Every other domain’s helper is named this way precisely so the weaker guarantee is visible at the call site.
A find-then-create helper is never named upsert, and a server-side upsert is never named createIfAbsent.
| Domain | Operation | Concurrency Guarantee |
|---|---|---|
LLM Connections |
|
Atomic. Backed by Langfuse’s own |
Blob Storage Integrations |
|
Atomic. Backed by Langfuse’s own |
Models |
|
Not atomic. Langfuse offers no upsert-by-name endpoint for models. This performs a lookup followed by a create; concurrent callers may both observe absence and both create. |
Datasets |
|
Not atomic. Lookup is a single request ( |
Score Configs |
|
Not atomic. Lookup followed by create. |
Evaluation Rules |
|
Not atomic. Cursor scan followed by create. |
Evaluators |
|
Not atomic. Cursor scan followed by create. |
Prompts |
|
Not atomic. A single-request lookup by name followed by a create. Note that Langfuse’s create endpoint adds a new version to an existing prompt; this method deliberately does not, and returns the existing prompt untouched instead. Use |
Annotation Queues |
|
Not atomic. Page scan followed by create. |
Annotation Queue Items |
|
Not atomic against concurrent writers. A single PATCH, but last write wins - Langfuse offers no conditional update. |
Any domain declaring it |
|
Not atomic. Langfuse offers no bulk delete endpoint, so a batch is client-side iteration and can partially apply. Inspect the returned |
Models, Evaluation Rules, Evaluators |
|
Not atomic. Resolution followed by delete, per identifier. A resource created between the lookup and the delete is not seen. |
Prompts |
|
Not atomic across a batch, but each name is a single server-side request with no resolution step - see Deleting by Name. |
LLM Connections |
|
Not atomic. Resolution followed by delete, per provider. |
Why the Surface Is Asymmetric
Some domains have findById and some do not. Some have delete and some do not. Two use upsert and the rest createIfAbsent. One list operation is unpaginated, one delete takes two identifiers, and one is keyed on a name.
That is the design, not an unfinished state. This layer gives each domain the shape its endpoint actually supports rather than a uniform one, because the alternative is worse: forcing symmetry means naming a method for something it cannot do, and in a public API a wrong name cannot be withdrawn. Each case below states what is absent and why, so it need not be read as an oversight.
Lookup
- Observations have no
findById -
The only GET-by-id for an observation is the v1 endpoint, which Langfuse has deprecated and will remove from Langfuse Cloud on November 16, 2026. Building a public method on an endpoint with a removal date would hand callers a method that stops working on a known date. The v2 listing, which this layer uses, has no by-id form.
- Blob storage integrations have
findStatusById, notfindById -
The
{id}GET on that endpoint answers aBlobStorageIntegrationStatusResponse- the status of the integration, such as whether its last export succeeded - and not the integration itself. A method namedfindByIdwould promise the integration and return something else, so the method is named for what it actually returns. - Experiments have neither
findByIdnorfindByName -
idandnameare comma-separated list criteria on that endpoint, not unique keys: either may select many experiments or none. Neither can be the basis of a lookup that returns at most one result, so both live onExperimentFilteras criteria instead. - Scores have no
findByName -
A
ScoreV3has a name, but it is not unique - many scores share one.nameis therefore a filter criterion, anddeleteByIdis the only delete. - Comments, dataset items, experiment items and annotation queue items have no
findByName -
Those models have no name to look up by. For the same reason none of them has
createIfAbsent: there is no key on which absence could be decided. - Evaluator versions have no lookup at all
-
The version history endpoint is the only way in, and it is a listing. Versions are read-only: they appear as a side effect of updating the evaluator and are removed only when the evaluator itself is deleted.
Deletion
- Comments and annotation queues have no delete
-
Langfuse exposes no DELETE endpoint for either. Annotation queue items do have one, reached through
langfuse.annotationQueues().items(queueId).deleteById(…). - Datasets and score configs have no delete
-
Unchanged from before this expansion, and still an upstream gap: Langfuse exposes no DELETE for either resource. (
DELETE /api/public/v2/datasets/{name}/runs/{runName}deletes a dataset run, which this layer does not model.) - Observations, experiments, experiment items and evaluator versions have no delete
-
No DELETE endpoint exists for any of them.
Creation
createon comments returns an id, not a comment-
Langfuse’s create endpoint answers a
CreateCommentResponsecarryingidand nothing else, soCommentOperations.createreturns aString. Returning aCommentwould require a second GET this layer does not silently issue on your behalf; if you want the created comment, follow up withfindById. - Observations, experiments, experiment items and evaluator versions have no
create -
Those collections are produced by ingestion or by other operations, and Langfuse exposes no create endpoint on them. Observations and scores arrive through the ingestion API, reachable via
api().
What This Layer Deliberately Does Not Cover
Two classes of endpoint are excluded as a standing rule. Neither is an oversight, and neither is awaiting work.
Deprecated endpoints
The layer is never built over an endpoint Langfuse has deprecated. A curated method is a promise of support, and these carry a removal date from Langfuse Cloud of November 16, 2026:
-
traces and sessions;
-
/api/public/v2/scores- the v3 listing (langfuse.scores()) supersedes it; -
v1 observations, including the GET-by-id discussed above - the v2 listing (
langfuse.observations()) supersedes it; -
dataset runs and dataset run items.
All of them remain reachable through api() for as long as Langfuse serves them.
/unstable/ endpoints
Endpoints published by Langfuse under /api/public/unstable/ - currently dashboards and dashboard widgets - are excluded as a project rule. Langfuse marks them as subject to change without notice, and a stable public method over an unstable endpoint would transfer that instability to this extension’s own API. They too remain reachable through api().
Configuration Reference
This layer is configured under quarkus.langfuse.api. Property names, types and defaults are listed in the full configuration reference, which is generated from the extension itself; the guidance below covers what that table cannot express.
Page and Batch Sizes
quarkus.langfuse.api.default-page-size and quarkus.langfuse.api.default-batch-size control how many items each request asks for while traversing a collection - the former for page-addressed collections, the latter for cursor-addressed ones. They are only defaults: an explicit PageSelection or CursorSelection passed to find(…) or stream(…) overrides them for that call.
Langfuse caps these per endpoint. A value above an endpoint’s cap is rejected by the server rather than by the extension.
Delete Concurrency
quarkus.langfuse.api.delete-concurrency bounds how many deletes from a single batch call are in flight at once. It is not a thread-pool size, and it does not limit how many identifiers you may pass.
A ceiling exists because Langfuse rate-limits. Unbounded fan-out would produce 429 responses that surface as Failed outcomes - manufacturing the very failures the result reports.
Setting it to 1 disables fan-out entirely: deletes run strictly one at a time on the calling thread. That is useful for deterministic debugging, for rate-limit-sensitive instances, and for reproducing a batch serially. Values below 1 are clamped to 1.
| The value changes only timing and request interleaving, never which outcomes are produced. Validation, deduplication and the deleted/not-found/failed contract are all independent of it. |
The practical ceiling is the REST client’s connection pool, which defaults to 50 concurrent connections (quarkus.rest-client."client".connection-pool-size). Raising delete-concurrency above the pool size achieves nothing, because the excess requests simply queue on the pool. If you genuinely need more parallelism, raise the pool first.