Compare commits

...
76 Commits
Author SHA1 Message Date
steffen911 b4bd8208c3 chore: release v3.69.0 2025-06-16 09:35:37 +02:00
Max DeichmannandGitHub c8b282dc82 chore: upgrade react email (#6358)
* chore: upgrade react email

* chore: upgrade react email

* chore: upgrade react email

* chore: upgrade react email

* upgrade

* upgrade
2025-06-15 13:05:52 +00:00
Max DeichmannandGitHub e2922aa787 fix: fix select all UI bug (#7312) 2025-06-15 12:13:25 +00:00
Max DeichmannandGitHub e8690eeb0b chore: clean up scripts (#7306)
chore: clean up
2025-06-13 23:05:34 +00:00
Max DeichmannandGitHub a695d58e8e chore: improve search tooltip (#7305)
push
2025-06-13 22:59:11 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b097a98444 chore(deps): bump next from 14.2.26 to 14.2.30 (#7301)
Bumps [next](https://github.com/vercel/next.js) from 14.2.26 to 14.2.30.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.26...v14.2.30)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.30
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-13 19:12:39 +00:00
Max DeichmannandGitHub 87c6d45a01 security: upgrade release-it (#7300) 2025-06-13 17:27:23 +00:00
d5a79b1830 chore(ui) Update LLM connection API key message (#7278)
Update API key description based on cloud or self-hosted deployment

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-13 16:22:56 +00:00
Steffen SchmitzandGitHub 40b967c258 fix: add auth headers to ctx within middleware, update not found logging (#7295) 2025-06-13 11:56:14 +00:00
marliessophieandGitHub b6b7a7c670 chore(dialog): move close button to DialogHeader for improved accessibility (#7294)
refactor(dialog): move close button to DialogHeader for improved accessibility
2025-06-13 11:08:07 +00:00
Hassieb PakzadandGitHub 346e787784 fix(prompts-api): make updates via API concurrency safe (#7287)
* fix(prompts-api): make updates via API concurrency safe

* push
2025-06-13 10:04:57 +00:00
Steffen SchmitzandGitHub 8e67f851b0 fix: keep zod/v3 schema for langchainjs interactions (#7293)
Patches https://github.com/langchain-ai/langchainjs/issues/8357
2025-06-13 09:33:40 +00:00
marliessophieandGitHub 8b416da2ef fix(PopoverFilterBuilder): ensure filter buttons do not submit forms (#7290) 2025-06-13 09:29:59 +00:00
marliessophieandGitHub 88237dcb30 fix(evals): add missing Dialog Body and Footer components (#7289) 2025-06-13 09:12:35 +00:00
Steffen SchmitzandGitHub 8a0b3962ac chore: upgrade zod to v4 (#7271)
* chore: upgrade zod to v4

* chore: docs

* chore: switch enums to non-deprecated api

* chore: remove unnecessary any

* chore: reverts

* chore: cleanup

* chore: cleanup

* chore: prompts filter tags

* chore: fix sync tests

* chore: patch tests
2025-06-13 08:04:27 +00:00
marliessophieandGitHub 2f6414bbf4 fix(dataset-items): add sorting by id for deterministic ordering (#7280) 2025-06-13 07:34:48 +00:00
c7aa992586 feat: add export functionality to audit log table (#7202)
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-06-12 20:48:58 +02:00
marliessophieandGitHub c2b5923e88 fix(NewDatasetItemForm): add JSON formatting utility for input, output, and metadata field (#7265)
* fix(NewDatasetItemForm): add JSON formatting utility for input, output, and metadata fields

* perf(NewDatasetItemFromExistingObject): conditionally render NewDatasetItemForm based on form state

* fix(NewDatasetItemForm): update JSON value check to handle undefined case
2025-06-12 17:57:22 +00:00
marliessophieandGitHub fc411d0c5c style(CommentList): reduce font size for author username (#7277) 2025-06-12 17:57:08 +00:00
marliessophieandGitHub 3fac8d2608 style(sessions): align header pills (#7282) 2025-06-12 16:38:55 +00:00
marliessophieandGitHub cd413fa887 style(TagButton): reduce size of spinner icon (#7281) 2025-06-12 16:29:11 +00:00
Marc KlingenandGitHub be7cc835be fix(trpcErrorToast): enhance error handling for 5xx server errors wit… (#7279)
fix(trpcErrorToast): enhance error handling for 5xx server errors with user-friendly message
2025-06-12 16:23:43 +00:00
marliessophieandGitHub b68896d14a refactor(DataTableToolbar): reposition PopoverFilterBuilder for improved UX (#7275) 2025-06-12 15:22:58 +00:00
Marc Klingen aae1909466 chore: release v3.68.0 2025-06-12 17:45:04 +02:00
Steffen SchmitzandGitHub 3d7960f43e fix: propagate searchQuery to deleteMany call (#7274)
* fix: propagate searchQuery to deleteMany call

* chore: address feedback

* chore: propagate filters from frontend
2025-06-12 14:16:35 +00:00
Hassieb PakzadandGitHub a121e24b79 feat(otel-ingestion): handle long-running traces (#7241)
* feat(otel-ingestion): handle long-running traces

* rename

* push

* push

* add tests

* add test

* use logger and update cache key

* refactor to OtelIngestionProcessor

* fix

* fix

* fix

* push

* fix graph view
2025-06-12 12:06:17 +00:00
7279a924ed fix: add llm_api_key_update to events, fix linting & call chain (#7160)
* feat: add ability to update llm api keys

* fix: add llm_api_key_update to events, fix linting & call chain

* fix: security issue, deleting last headers, typing

* fix: remove unused variable

* fix: remove unused variable

* refactor: align LlmApiKeys type with shared package across the components

* push fixes

* push fixes

* push fixes

* push fixes

* push fixes

* push fixes

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-06-12 11:57:38 +00:00
Max DeichmannandGitHub a10951b7e5 chore: allow trace deletions on entire history (#7252)
push
2025-06-12 11:57:04 +00:00
marliessophieandGitHub e456b08f92 fix(trace-graph): validate node existence before selection and handle errors gracefully (#7272) 2025-06-12 11:46:02 +00:00
85130cecb3 feat(cloud): 2-step sign in to make enterprise sso easier (#7250)
* chore: update `pnpm dx-f` to require no confirmation

* chore: setup cursor background agent

* Implement two-step login flow with SSO domain detection

* Simplify SSO fallback logic in sign-in page

* Conditionally render credentials form based on auth provider config

* Reorder SSO buttons and error display in sign-in page layout

* Checkpoint before follow-up message

* add focus password

* skip two step if no sso configured

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-06-12 09:52:29 +00:00
marliessophieandGitHub fbb15e26ec fix(dialog): adjust dialog component styling to prevent overflow issues (#7264)
* fix(dialog): adjust dialog component styling to prevent overflow issues

* refactor(dialog): simplify dialog footer styling across multiple components
2025-06-12 08:36:33 +00:00
Marc KlingenandGitHub dd203a1f18 chore: setup cursor background agent (#7251)
* chore: update `pnpm dx-f` to require no confirmation

* chore: setup cursor background agent

* push
2025-06-11 22:06:15 +00:00
Marc KlingenandGitHub 06c25f9ec3 chore: update pnpm dx-f to require no confirmation (#7249) 2025-06-11 21:23:07 +00:00
marliessophieandGitHub 7072ca2786 fix(popover): update max-width calculation for popover component to use fit-content (#7248) 2025-06-11 20:30:12 +00:00
marliessophieandGitHub cb1e5f4c19 fix(TracesTable): stabilize control column definition (#7247)
* fix(TracesTable): stabilize control column definition

- Ensured conditional rendering of the bookmarked column remains intact while simplifying the structure.

* refactor(TracesTable): enhance column visibility and order identifiers for control states
2025-06-11 19:36:46 +00:00
Steffen SchmitzandGitHub d4a28aa164 chore: upgrade clickhouse client dependency (#7246) 2025-06-11 18:40:43 +00:00
marliessophieandGitHub fda5fcdd0c refactor(dialog): improve dialog component layout and styling for billing settings (#7245)
- Updated dialog header to use a title component and improved button layout for clarity.
- Adjusted positioning of the close button for better alignment.
- Enhanced header and footer with rounded corners for a more polished look.
2025-06-11 16:39:42 +00:00
Marc KlingenandGitHub 24131e0791 feat(cloud): add stripe productid of cloud enterprise plan (#7244) 2025-06-11 16:14:16 +00:00
Marc KlingenandGitHub ffe132403e chore(cloud): increase user limit on core plan (#7243) 2025-06-11 15:51:39 +00:00
marliessophieandGitHub cce3bf35e1 fix(CreateExperimentsForm): clear modelConfig errors when valid model parameters are set and adjust layout spacing (#7233) 2025-06-11 10:34:06 +00:00
marliessophieandGitHub eccfec36e8 feat(MarkdownViewer): add URL sanitization using DOMPurify for safe link rendering (#7232) 2025-06-11 09:31:29 +00:00
Hassieb PakzadandGitHub a71faa4e60 feat(model-prices): add o1-pro (#7229) 2025-06-11 08:41:26 +00:00
b7362258c6 fix(ui): enable clipboard copying for self-hosted instances (#7227)
* fix(ui): Enable clipboard copying in insecure browser contexts

* Fixes #5541

* Adds clipboard utility file to manage copy interactions

* Implements fallback method for scenarios lacking Clipboard API
  support in insecure contexts (e.g., HTTP resources not delivered locally)

* Suggested review comments addressed

* chore: eslint

* chore: apply copy functionality to new occurrences

---------

Co-authored-by: akshitvijay <akshitthevijay@gmail.com>
2025-06-11 08:35:08 +00:00
Hassieb PakzadandGitHub 5eb1d98985 fix(graph-view): do not JSON parse node names (#7228) 2025-06-11 10:13:06 +02:00
Marlies Mayerhofer 1c574b2a5d chore: release v3.67.0 2025-06-11 01:39:36 +02:00
marliessophieandGitHub 6db9b87b6a chore(models): adjust price of o3 to reflected updated openai pricing (#7223) 2025-06-10 22:30:07 +00:00
marliessophieandGitHub 3e3ccb65ff feat(models): add o3-pro for cost tracking (#7222)
* feat(models): add o3-pro for playground and costs

* chore: remove from playground as langchain does not support o3 model for completions
2025-06-10 22:23:28 +00:00
marliessophieandGitHub 0d53b443e5 style(CreateScoreConfigButton): add padding to confirmation message for improved readability (#7220) 2025-06-10 16:26:16 +00:00
marliessophieandGitHub 1b795a3dcf chore(evals): show warning if API key was deleted (#7053)
* chore(evals): show warning if API key was deleted

* chore: fix typo
2025-06-10 15:44:36 +00:00
Hassieb PakzadandGitHub 2e94bc9e09 fix(graph-view): handle missing __start__ node for langgraph>=0.4 (#7215) 2025-06-10 15:44:12 +00:00
marliessophieandGitHub 8b94e8a3bd fix(annotation): handle deletes gracefully and protect against race conditions (#6848) 2025-06-10 15:00:43 +00:00
marliessophieandGitHub 91730a2eaf refactor(dialogs): dialog components with improved structure and styling (#7218) 2025-06-10 14:40:26 +00:00
marliessophieandGitHub 4f1a2337be fix(datasets): prevent delete modal from closing on mouse navigation event (#7163) 2025-06-10 14:30:05 +00:00
Hassieb PakzadandGitHub 4cf5aa03ec fix(trpc-prompts-api): handle prompt not found case on setLabels (#7216) 2025-06-10 13:00:23 +00:00
Steffen SchmitzandGitHub 922eafd4f6 chore: refactor chart display names into single util (#7209) 2025-06-10 07:29:06 +00:00
Hassieb PakzadandGitHub 9067f80206 fix(evals): do not throw errors if eval model does not consistently return JSON (#7187) 2025-06-08 14:05:51 +00:00
Steffen SchmitzandGitHub a7ca1fb269 chore: show frontend name for histogram chart type (#7181) 2025-06-06 15:09:54 +00:00
Hassieb PakzadandGitHub b51f321d22 feat(quickstart+readme): add python SDK v3 examples (#7157) 2025-06-06 16:36:16 +02:00
Hassieb PakzadandGitHub dd048a64a0 chore: upgrade langchain (#7178)
* chore: upgrade langchain

* push
2025-06-06 11:41:08 +00:00
ClemoandGitHub c6c19de0e8 docs: update readme, add terraform templates 2025-06-06 11:25:16 +02:00
marliessophieandGitHub 5491f176e1 chore(datasets): simplify DatasetAnalytics component interaction and integrate local storage for chart metrics (#7161)
* chore(datasets): simplify DatasetAnalytics component interaction and integrate local storage for chart metrics

* chore: eslint
2025-06-05 16:10:06 +00:00
marliessophieandGitHub 7a28473ebb perf(traces-table): improve performance (#7126)
* fixup: limit render duration of traces table

* fixup: reduce render cycles through data flows and memoization

* refactor: enhance performance and structure of table components

- Removed unnecessary console logs in `data-table.tsx`.
- Refactored `TablePeekView` to use a more structured props type and memoization for improved performance.
- Updated `usePeekData` to reduce stale time from 5 minutes to 1 minute.
- Replaced `IOTableCell` with `MemoizedIOTableCell` in various components to optimize rendering.
- Adjusted `peekView` configurations in `observations.tsx` and `traces.tsx` for consistency and clarity.

* chore: eslint
2025-06-05 14:16:10 +00:00
Steffen SchmitzandGitHub 005ce7318c chore: allow bullmq-api for non-cloud deployments (#7156) 2025-06-05 12:22:18 +00:00
marliessophieandGitHub 448c56e114 feat(dialog): add sticky header and footer (#7154)
* chore(ui-components): add dialog body

* style(popover): update width styling to use CSS variable for dynamic sizing

* feat(dialog): add DialogBody and DialogFooter components to enhance dialog structure

* chore: eslint

* chore: adjust modal height
2025-06-05 12:05:31 +00:00
Hassieb PakzadandGitHub 1d6e498e2b fix(otel): read resourceAttributes for release attribute (#7155) 2025-06-05 11:38:13 +00:00
Steffen SchmitzandGitHub 33ab717ae0 chore: include LANGFUSE_USE_AZURE_BLOB in docker-compose options (#7153) 2025-06-05 08:29:05 +00:00
Steffen SchmitzandGitHub ec563b775b feat(dashboards): add histogram chart type (#7140)
* feat(dashboards): add histogram chart type

* chore: update querybuilder behaviour

* chore: lint

* chore: push

* chore: format numbers nicely

* chore: store histogram bins in chartConfig

* chore: change order

* chore: add histogram function to metrics api

* chore: docs

* chore: generate docs
2025-06-05 07:43:35 +00:00
marliessophieandGitHub de427b5572 chore(evals): implement update confirmation for default evaluation model (#7137)
* chore(evals): implement update confirmation for default evaluation model

* chore: adjust alert to confirm

* chore: adjust alert to confirm
2025-06-04 17:08:44 +00:00
Marc Klingen 95921fa5da chore: release v3.66.1 2025-06-04 11:09:25 +02:00
Marc KlingenandGitHub 9e4d352366 fix(ee): pro plan label (#7136)
fix(ee): self-hosted pro plan label
2025-06-04 08:57:50 +00:00
steffen911 02449cbe0a chore: release v3.66.0 2025-06-04 09:24:50 +02:00
Steffen SchmitzandGitHub 616c68a87b feat: add support for new OTEL semantic event types (#7135) 2025-06-04 07:06:07 +00:00
Steffen SchmitzandGitHub 319b22ae78 chore: disentangle batch actions from batch exports (#7092)
* chore: disentangle batch actions from batch exports

* chore: revert

* chore: revert

* chore: revert
2025-06-04 06:53:52 +00:00
Max Deichmann b919562eed chore: release v3.65.3 2025-06-04 01:17:23 +02:00
Max DeichmannandGitHub 91c01e7363 chore: allow annotation queues on FOSS (#7130)
chore: move features
2025-06-03 22:17:02 +00:00
Max DeichmannandGitHub 48ec1bccae fix: fix full text search selector (#7129)
* push

* fix: annotation queue count

* chore: move features

* chore: move features

* chore: move features
2025-06-03 22:04:49 +00:00
364 changed files with 11950 additions and 7827 deletions
+47
View File
@@ -0,0 +1,47 @@
FROM node:20
# ---------- System packages --------------------------------------------------
# The buildpack-deps base already ships git, build-essential, python, etc.
# Add a few extra tools handy during Langfuse development.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
openssl \
wget \
curl \
ca-certificates \
postgresql-client \
redis-tools \
less nano \
&& rm -rf /var/lib/apt/lists/*
# ---------- pnpm -------------------------------------------------------------
# Langfuse monorepo relies on pnpm 9.5.0 (see CONTRIBUTING.md)
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && \
corepack prepare pnpm@9.5.0 --activate
# ---------- golang-migrate ----------------------------------------------------
# CLI used for database migrations during development.
ENV MIGRATE_VERSION=4.18.2
RUN wget -qO- "https://github.com/golang-migrate/migrate/releases/download/v${MIGRATE_VERSION}/migrate.linux-amd64.tar.gz" \
| tar -xz -C /usr/local/bin && \
chmod +x /usr/local/bin/migrate
# ---------- Non-root user -----------------------------------------------------
# Use root for convenience in development containers
WORKDIR /workspace
# Create non-root user
RUN useradd -ms /bin/bash ubuntu
# Pre-create pnpm store and set correct ownership to avoid first-run cost & permission issues
RUN pnpm store path > /dev/null && \
chown -R ubuntu:ubuntu /pnpm
USER ubuntu
WORKDIR /home/ubuntu
ENV HOME=/home/ubuntu
# Container starts with a bash shell ready for hacking.
CMD ["bash"]
+8
View File
@@ -0,0 +1,8 @@
{
"install": "cp .env.dev.example .env && pnpm i",
"build": {
"context": ".",
"dockerfile": "Dockerfile"
},
"start": "pnpm dx-f"
}
+8
View File
@@ -0,0 +1,8 @@
---
description:
globs:
alwaysApply: true
---
# General rules
- Linting in this repo only works if the development server is running
+2 -2
View File
@@ -213,8 +213,8 @@ LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 欧盟区域
创建示例代码(文件名:**main.py**):
````python:main.py
from langfuse.decorators import observe
```python:main.py
from langfuse import observe
from langfuse.openai import openai # OpenAI 集成
@observe()
+1 -1
View File
@@ -219,7 +219,7 @@ LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EUリージョン
```
```python:/@observe()/ /from langfuse.openai import openai/ filename="main.py"
from langfuse.decorators import observe
from langfuse import observe
from langfuse.openai import openai # OpenAI統合
@observe()
+1 -1
View File
@@ -201,7 +201,7 @@ LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EU region
```
```python:main.py
from langfuse.decorators import observe
from langfuse import observe
from langfuse.openai import openai # OpenAI integration
@observe()
+5 -6
View File
@@ -93,7 +93,7 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
### Langfuse Cloud
Managed deployment by the Langfuse team, generous free-tier (hobby plan), no credit card required.
Managed deployment by the Langfuse team, generous free-tier, no credit card required.
<div align="center">
<a href="https://cloud.langfuse.com" target="_blank">
@@ -115,12 +115,11 @@ Run Langfuse on your own infrastructure:
# Run the langfuse docker compose
docker compose up
```
- [Kubernetes (Helm)](https://langfuse.com/self-hosting/kubernetes-helm): Run Langfuse on a Kubernetes cluster using Helm. This is the preferred production deployment.
- [VM](https://langfuse.com/self-hosting/docker-compose): Run Langfuse on a single Virtual Machine using Docker Compose.
- Planned: Cloud-specific deployment guides, please upvote and comment on the following threads: [AWS](https://github.com/orgs/langfuse/discussions/4645), [Google Cloud](https://github.com/orgs/langfuse/discussions/4646), [Azure](https://github.com/orgs/langfuse/discussions/4647).
- [Kubernetes (Helm)](https://langfuse.com/self-hosting/kubernetes-helm): Run Langfuse on a Kubernetes cluster using Helm. This is the preferred production deployment.
- Terraform Templates: [AWS](https://langfuse.com/self-hosting/aws), [Azure](https://langfuse.com/self-hosting/azure), [GCP](https://langfuse.com/self-hosting/gcp)
See [self-hosting documentation](https://langfuse.com/self-hosting) to learn more about the architecture and configuration options.
See [self-hosting documentation](https://langfuse.com/self-hosting) to learn more about architecture and configuration options.
## 🔌 Integrations
@@ -191,7 +190,7 @@ LANGFUSE_HOST="https://cloud.langfuse.com" # 🇪🇺 EU region
```
```python /@observe()/ /from langfuse.openai import openai/ filename="main.py"
from langfuse.decorators import observe
from langfuse import observe
from langfuse.openai import openai # OpenAI integration
@observe()
+1
View File
@@ -29,6 +29,7 @@ services:
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
LANGFUSE_USE_AZURE_BLOB: ${LANGFUSE_USE_AZURE_BLOB:-false}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto}
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
+2 -2
View File
@@ -28,9 +28,9 @@
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"axios": "^1.8.2",
"https-proxy-agent": "^7.0.6",
"next": "^14.2.26",
"next": "^14.2.30",
"next-auth": "^4.24.11",
"zod": "^3.24.4"
"zod": "^3.25.62"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
+7 -2
View File
@@ -28,7 +28,7 @@ service:
"metrics": [ // Required. At least one metric must be provided
{
"measure": string, // What to measure, e.g. "count", "latency", "value"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram"
}
],
"filters": [ // Optional. Default: []
@@ -50,7 +50,11 @@ service:
"field": string, // Field to order by
"direction": string // "asc" or "desc"
}
]
],
"config": { // Optional. Query-specific configuration
"bins": number, // Optional. Number of bins for histogram (1-100), default: 10
"row_limit": number // Optional. Row limit for results (1-1000)
}
}
```
response: MetricsResponse
@@ -62,3 +66,4 @@ types:
docs: |
The metrics data. Each item in the list contains the metric values and dimensions requested in the query.
Format varies based on the query parameters.
Histograms will return an array with [lower, upper, height] tuples.
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.65.2",
"version": "3.69.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -18,7 +18,7 @@
"db:seed:examples": "turbo run db:seed:examples",
"nuke": "bash ./scripts/nuke.sh",
"dx": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset -f && SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"start": "turbo run start",
@@ -31,12 +31,12 @@
"prepare": "husky"
},
"devDependencies": {
"@release-it/bumper": "^7.0.1",
"@release-it/bumper": "^7.0.5",
"braces": "3.0.3",
"dotenv-cli": "^7.4.2",
"husky": "^9.0.11",
"prettier": "^3.3.3",
"release-it": "^18.1.2",
"release-it": "^19.0.3",
"turbo": "^1.13.4"
},
"release-it": {
+12 -4
View File
@@ -36,8 +36,12 @@ if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-migrations-table-engine=MergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
# If SKIP_CONFIRM is set, automatically answer the confirmation prompt. Otherwise run interactively.
if [ "$SKIP_CONFIRM" = "1" ] || [ "$SKIP_CONFIRM" = "true" ]; then
printf 'y\n' | migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
else
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
fi
else
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
@@ -45,6 +49,10 @@ else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=${CLICKHOUSE_DB}&x-multi-statement=true&x-cluster-name=${CLICKHOUSE_CLUSTER_NAME}&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" down
# If SKIP_CONFIRM is set, automatically answer the confirmation prompt. Otherwise run interactively.
if [ "$SKIP_CONFIRM" = "1" ] || [ "$SKIP_CONFIRM" = "true" ]; then
printf 'y\n' | migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" down
else
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" down
fi
fi
+13 -12
View File
@@ -60,18 +60,18 @@
"@aws-sdk/lib-storage": "^3.675.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.11.1",
"@clickhouse/client": "^1.11.2",
"@google-cloud/storage": "^7.15.2",
"@langchain/anthropic": "^0.3.12",
"@langchain/aws": "^0.1.3",
"@langchain/core": "^0.3.37",
"@langchain/google-genai": "^0.1.9",
"@langchain/google-vertexai": "^0.1.8",
"@langchain/openai": "^0.3.17",
"@langchain/anthropic": "^0.3.21",
"@langchain/aws": "^0.1.10",
"@langchain/core": "^0.3.57",
"@langchain/google-genai": "^0.2.10",
"@langchain/google-vertexai": "^0.2.10",
"@langchain/openai": "^0.5.12",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"@prisma/client": "^6.3.0",
"@react-email/components": "^0.0.19",
"@react-email/render": "^0.0.15",
"@react-email/components": "^0.0.42",
"@react-email/render": "^1.1.2",
"@types/bcryptjs": "^2.4.6",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
@@ -82,8 +82,8 @@
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.4.1",
"kysely": "^0.27.4",
"langchain": "^0.3.15",
"langfuse-langchain": "3.30.3",
"langchain": "^0.3.27",
"langfuse-langchain": "3.37.4",
"lodash": "^4.17.21",
"lossless-json": "^4.0.2",
"next-auth": "^4.24.11",
@@ -91,7 +91,7 @@
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
"winston": "^3.15.0",
"zod": "^3.24.4",
"zod": "^3.25.62",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
@@ -101,6 +101,7 @@
"@types/node": "^20.11.29",
"@types/nodemailer": "^6.4.16",
"@types/pg": "^8.11.10",
"@types/react": "18.2.79",
"@types/uuid": "^9.0.8",
"@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.57.0",
+2 -1
View File
@@ -110,7 +110,8 @@ export const DashboardWidgetChartType = {
HORIZONTAL_BAR: "HORIZONTAL_BAR",
VERTICAL_BAR: "VERTICAL_BAR",
PIE: "PIE",
NUMBER: "NUMBER"
NUMBER: "NUMBER",
HISTOGRAM: "HISTOGRAM"
} as const;
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
export type Account = {
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'HISTOGRAM';
+1
View File
@@ -1156,6 +1156,7 @@ enum DashboardWidgetChartType {
VERTICAL_BAR
PIE
NUMBER
HISTOGRAM
}
model DashboardWidget {
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { jsonSchema } from "../utils/zod";
import { MetadataDomain } from "./traces";
+2 -2
View File
@@ -1,5 +1,5 @@
import { ScoreDataType } from "@prisma/client";
import z from "zod";
import z from "zod/v4";
import { MetadataDomain } from "./traces";
export const ScoreSource = {
@@ -31,7 +31,7 @@ export const ScoreSchema = z.object({
queueId: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
dataType: z.nativeEnum(ScoreDataType),
dataType: z.enum(ScoreDataType),
});
export type ScoreDomain = z.infer<typeof ScoreSchema>;
@@ -1,6 +1,6 @@
import { singleFilter } from "../interfaces/filters";
import { orderBy } from "../interfaces/orderBy";
import z from "zod";
import z from "zod/v4";
export enum TableViewPresetTableName {
Traces = "traces",
@@ -17,7 +17,7 @@ const TableViewPresetDomainSchema = z.object({
updatedAt: z.date(),
createdBy: z.string().nullable(),
name: z.string(),
tableName: z.nativeEnum(TableViewPresetTableName),
tableName: z.enum(TableViewPresetTableName),
filters: z.array(singleFilter),
columnOrder: z.array(z.string()),
columnVisibility: z.record(z.string(), z.boolean()),
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { jsonSchema, jsonSchemaNullable } from "../utils/zod";
export const MetadataDomain = z.record(
+3 -8
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { removeEmptyEnvVariables } from "./utils/environment";
const EnvSchema = z.object({
@@ -9,10 +9,7 @@ const EnvSchema = z.object({
NEXTAUTH_URL: z.string().url().optional(),
REDIS_HOST: z.string().nullish(),
REDIS_PORT: z.coerce
.number({
description:
".env files convert numbers to strings, therefore we have to enforce them to be numbers",
})
.number() // .env files convert numbers to strings, therefore we have to enforce them to be numbers
.positive()
.max(65536, `options.port should be >= 0 and < 65536`)
.default(6379)
@@ -60,9 +57,7 @@ const EnvSchema = z.object({
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_CONCURRENT_WRITES: z.coerce.number().positive().default(50),
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
required_error: "Langfuse requires a bucket name for S3 Event Uploads.",
}),
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string(), // Langfuse requires a bucket name for S3 Event Uploads.
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: z.string().default(""),
LANGFUSE_S3_EVENT_UPLOAD_REGION: z.string().optional(),
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: z.string().optional(),
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { type ScoreDataType } from "../../db";
const NUMERIC: ScoreDataType = "NUMERIC";
@@ -1,7 +1,8 @@
import z from "zod";
import z from "zod/v4";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { BatchExportTableName } from "../batchExport/types";
import { BatchTableNames } from "../../interfaces/tableNames";
import { TracingSearchType } from "../../interfaces/search";
/* eslint-disable no-unused-vars */
export enum BatchActionType {
@@ -20,6 +21,8 @@ export type ActionId = z.infer<typeof ActionIdSchema>;
export const BatchActionQuerySchema = z.object({
filter: z.array(singleFilter).nullable(),
orderBy,
searchQuery: z.string().optional(),
searchType: z.array(TracingSearchType).optional(),
});
export type BatchActionQuery = z.infer<typeof BatchActionQuerySchema>;
@@ -29,11 +32,11 @@ export const CreateBatchActionSchema = z.object({
actionId: ActionIdSchema,
targetId: z.string().optional(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
});
export const GetIsBatchActionInProgressSchema = z.object({
projectId: z.string(),
actionId: ActionIdSchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
});
@@ -1,9 +1,10 @@
import z from "zod";
import z from "zod/v4";
import { BatchExport } from "@prisma/client";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { BatchTableNames } from "../../interfaces/tableNames";
export enum BatchExportStatus {
QUEUED = "QUEUED",
@@ -18,13 +19,9 @@ export enum BatchExportFileFormat {
JSONL = "JSONL",
}
export enum BatchExportTableName {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
}
// Use shared BatchTableNames enum for consistency across batch operations
// Keep BatchExportTableName as alias for backward compatibility
export { BatchTableNames as BatchExportTableName };
export const exportOptions: Record<
BatchExportFileFormat,
@@ -44,7 +41,7 @@ export const exportOptions: Record<
} as const;
export const BatchExportQuerySchema = z.object({
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
filter: z.array(singleFilter).nullable(),
orderBy,
limit: z.number().optional(),
@@ -57,7 +54,7 @@ export const CreateBatchExportSchema = z.object({
projectId: z.string(),
name: z.string(),
query: BatchExportQuerySchema,
format: z.nativeEnum(BatchExportFileFormat),
format: z.enum(BatchExportFileFormat),
});
export const BatchExportSchema = z.object({
@@ -69,9 +66,9 @@ export const BatchExportSchema = z.object({
finishedAt: z.date().nullable(),
expiresAt: z.date().nullable(),
name: z.string(),
status: z.nativeEnum(BatchExportStatus),
status: z.enum(BatchExportStatus),
query: BatchExportQuerySchema,
format: z.nativeEnum(BatchExportFileFormat),
format: z.enum(BatchExportFileFormat),
url: z.string().nullable(),
log: z.string().nullable(),
});
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
const MAX_COMMENT_LENGTH = 3000;
@@ -14,6 +14,7 @@ export const planLabels = {
"cloud:pro": "Pro",
"cloud:team": "Team",
"cloud:enterprise": "Enterprise",
"self-hosted:pro": "Pro (self-hosted)",
"self-hosted:enterprise": "Enterprise (self-hosted)",
} as const;
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
export const langfuseObjects = [
"trace",
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { variableMapping } from "./types";
/**
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const PromptDependencyRegex = /@@@langfusePrompt:(.*?)@@@/g;
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { jsonSchema, publicApiPaginationZod } from "../../../../utils/zod";
import { stringDateTime } from "../../../../utils/typeChecks";
import { applyScoreValidation } from "../../../../utils/scores";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { paginationMetaResponseZod } from "../../../../../utils/zod";
import {
DeleteScoreQuery,
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import {
CategoricalData,
NumericData,
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { GetScoreResponseDataV1 } from "./endpoints";
/**
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { paginationMetaResponseZod } from "../../../../../utils/zod";
import { GetScoreQuery, GetScoresQuery } from "../shared";
import { APIScoreSchemaV2 } from "./schemas";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import {
CategoricalData,
NumericData,
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { GetScoreResponseDataV2 } from "./endpoints";
/**
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { APIScoreSchemaV2, APIScoreV2 } from "../api/v2/schemas";
import { APIScoreSchemaV1, APIScoreV1 } from "../api/v1/schemas";
import { ScoreDomain } from "../../../../domain";
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { applyScoreValidation } from "../../../../utils/scores";
import { PostScoreBodyFoundationSchema } from "../shared";
import { isPresent } from "../../../../utils/typeChecks";
@@ -1,5 +1,5 @@
import { jsonSchema } from "../../../utils/zod";
import z from "zod";
import z from "zod/v4";
import { NonEmptyString } from "../../../utils/zod";
/**
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
@@ -68,10 +68,10 @@ const CategoricalScoreConfig = z.object({
const parseResult = Categories.safeParse(categories);
if (!parseResult.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message:
"Category must be an array of objects with label value pairs, where labels and values are unique.",
});
} as z.core.$ZodIssueCustom);
return;
}
@@ -198,7 +198,7 @@ export const GetScoreConfigResponse = ValidatedScoreConfigSchema;
// POST /score-configs
export const PostScoreConfigBody = z
.union([
.discriminatedUnion("dataType", [
ScoreConfigPostBase.merge(CategoricalScoreConfig),
ScoreConfigPostBase.merge(NumericScoreConfig),
ScoreConfigPostBase.merge(
+1
View File
@@ -26,6 +26,7 @@ export * from "./features/evals/utilities";
// table actions
export * from "./features/batchExport/types";
export * from "./features/batchAction/types";
export { BatchTableNames } from "./interfaces/tableNames";
// annotation
export * from "./features/annotation/types";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { CloudConfigRateLimit } from "./rate-limits";
import { cloudConfigPlans } from "../features/entitlements/plans";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const BedrockConfigSchema = z.object({ region: z.string() });
export type BedrockConfig = z.infer<typeof BedrockConfigSchema>;
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const filterOperators = {
datetime: [">", "<", ">=", "<="],
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const orderBy = z
.object({
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { ApiAccessScope } from "../server";
export const RateLimitResource = z.enum([
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const TracingSearchType = z.enum(["id", "content"]);
// id: for searching smaller columns like IDs, types, and other metadata
@@ -0,0 +1,13 @@
/**
* Shared table names used across batch operations (exports, actions, etc.)
* This enum provides a centralized definition of database table names
* to avoid coupling between different batch operation types.
*/
export enum BatchTableNames {
Scores = "scores",
Sessions = "sessions",
Traces = "traces",
Observations = "observations",
DatasetRunItems = "dataset_run_items",
AuditLogs = "audit_logs",
}
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { Plan, plans } from "../../features/entitlements/plans";
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
import { ApiKeyScope } from "../../";
+1 -1
View File
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
import { FilterState } from "../types";
import { filterOperators, timeFilter } from "../interfaces/filters";
import { z } from "zod";
import { z } from "zod/v4";
import { logger } from "./index";
const operatorReplacements = {
@@ -1,5 +1,5 @@
import { randomUUID } from "crypto";
import { z } from "zod";
import { z } from "zod/v4";
import { type Model } from "../../db";
import { env } from "../../env";
@@ -1,5 +1,5 @@
import lodash from "lodash";
import { z } from "zod";
import { z } from "zod/v4";
import { NonEmptyString, jsonSchema } from "../../utils/zod";
import { ModelUsageUnit } from "../../constants";
@@ -20,7 +20,7 @@ export const Usage = z.object({
input: z.number().int().nullish(),
output: z.number().int().nullish(),
total: z.number().int().nullish(),
unit: z.nativeEnum(ModelUsageUnit).nullish(),
unit: z.enum(ModelUsageUnit).nullish(),
inputCost: z.number().nullish(),
outputCost: z.number().nullish(),
totalCost: z.number().nullish(),
@@ -30,7 +30,7 @@ const MixedUsage = z.object({
input: z.number().int().nullish(),
output: z.number().int().nullish(),
total: z.number().int().nullish(),
unit: z.nativeEnum(ModelUsageUnit).nullish(),
unit: z.enum(ModelUsageUnit).nullish(),
promptTokens: z.number().int().nullish(),
completionTokens: z.number().int().nullish(),
totalTokens: z.number().int().nullish(),
@@ -128,8 +128,7 @@ const OpenAICompletionUsageSchema = z
}
return result;
})
.pipe(RawUsageDetails);
});
// The new OpenAI Response API uses a new Usage schema that departs from the Completion API Usage schema
const OpenAIResponseUsageSchema = z
@@ -184,8 +183,7 @@ const OpenAIResponseUsageSchema = z
}
return result;
})
.pipe(RawUsageDetails);
});
export const UsageDetails = z
.union([
@@ -266,7 +264,7 @@ export const CreateGenerationBody = CreateSpanBody.extend({
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string()),
z.record(z.string(), z.string()),
])
.nullish(),
)
@@ -296,7 +294,7 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string()),
z.record(z.string(), z.string()),
])
.nullish(),
)
@@ -162,7 +162,7 @@ function validateConfigAgainstBody(
});
if (!rangeValidation.success) {
const errorDetails = rangeValidation.error.errors
const errorDetails = rangeValidation.error.issues
.map((error) => `${error.path.join(".")} - ${error.message}`)
.join(", ");
@@ -1,3 +1,5 @@
// We continue to use zod v3 for langchainjs.
// Corresponding issue report: https://github.com/langchain-ai/langchainjs/issues/8357.
import { type ZodSchema } from "zod";
import { ChatAnthropic } from "@langchain/anthropic";
@@ -16,7 +18,7 @@ import {
StringOutputParser,
} from "@langchain/core/output_parsers";
import { IterableReadableStream } from "@langchain/core/utils/stream";
import { ChatOpenAI } from "@langchain/openai";
import { ChatOpenAI, AzureChatOpenAI } from "@langchain/openai";
import GCPServiceAccountKeySchema, {
BedrockConfigSchema,
BedrockCredentialSchema,
@@ -219,7 +221,7 @@ export async function fetchLLMCompletion(
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
} else if (modelParams.adapter === LLMAdapter.Azure) {
chatModel = new ChatOpenAI({
chatModel = new AzureChatOpenAI({
azureOpenAIApiKey: apiKey,
azureOpenAIBasePath: baseURL,
azureOpenAIApiDeploymentName: modelParams.model,
+4 -4
View File
@@ -1,5 +1,5 @@
import { LlmApiKeys } from "@prisma/client";
import z from "zod";
import z from "zod/v4";
import { BedrockConfigSchema } from "../../interfaces/customLLMProviderConfigSchemas";
import { TokenCountDelegate } from "../ingestion/processEventBatch";
import { AuthHeaderValidVerificationResult } from "../auth/types";
@@ -28,7 +28,7 @@ export const JSONSchemaFormSchema = z
z
.object({
type: z.literal("object"),
properties: z.record(z.any()),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
})
@@ -168,7 +168,7 @@ export const ToolResultMessageSchema = z.object({
});
export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
export const ChatMessageDefaultRoleSchema = z.enum(ChatMessageRole);
export const ChatMessageSchema = z.union([
SystemMessageSchema,
DeveloperMessageSchema,
@@ -370,7 +370,7 @@ export const LLMApiKeySchema = z
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
adapter: z.nativeEnum(LLMAdapter),
adapter: z.enum(LLMAdapter),
provider: z.string(),
displaySecretKey: z.string(),
secretKey: z.string(),
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { decrypt } from "../../encryption";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Prisma } from "@prisma/client";
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { singleFilter } from "../../../interfaces/filters";
import { FilterCondition } from "../../../types";
import { isValidTableName } from "../../clickhouse/schemaUtils";
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { OrderByState } from "../../../interfaces/orderBy";
import { UiColumnMappings } from "../../../tableDefinitions";
import { logger } from "../../logger";
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Prisma } from "@prisma/client";
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { optionalPaginationZod } from "../../utils/zod";
+10 -10
View File
@@ -1,15 +1,15 @@
/* eslint-disable no-unused-vars */
import { z } from "zod";
import { eventTypes } from ".";
import { z } from "zod/v4";
import { eventTypes } from "./ingestion/types";
import {
BatchActionQuerySchema,
BatchActionType,
} from "../features/batchAction/types";
import { BatchExportTableName } from "../features/batchExport/types";
import { BatchTableNames } from "../interfaces/tableNames";
export const IngestionEvent = z.object({
data: z.object({
type: z.nativeEnum(eventTypes),
type: z.enum(Object.values(eventTypes)),
eventBodyId: z.string(),
fileKey: z.string().optional(),
skipS3List: z.boolean().optional(),
@@ -76,28 +76,28 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("score-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("trace-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("trace-add-to-annotation-queue"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.enum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("eval-create"),
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
export const clickhouseStringDateSchema = z
.string()
@@ -38,7 +38,7 @@ export const observationRecordBaseSchema = z.object({
parent_observation_id: z.string().nullish(),
environment: z.string().default("default"),
name: z.string().nullish(),
metadata: z.record(z.string()),
metadata: z.record(z.string(), z.string()),
level: z.string().nullish(),
status_message: z.string().nullish(),
version: z.string().nullish(),
@@ -92,7 +92,7 @@ export const traceRecordBaseSchema = z.object({
id: z.string(),
name: z.string().nullish(),
user_id: z.string().nullish(),
metadata: z.record(z.string()),
metadata: z.record(z.string(), z.string()),
release: z.string().nullish(),
version: z.string().nullish(),
project_id: z.string(),
@@ -134,7 +134,7 @@ export const scoreRecordBaseSchema = z.object({
value: z.number().nullish(),
source: z.string(),
comment: z.string().nullish(),
metadata: z.record(z.string()),
metadata: z.record(z.string(), z.string()),
author_user_id: z.string().nullish(),
config_id: z.string().nullish(),
data_type: z.enum(["NUMERIC", "CATEGORICAL", "BOOLEAN"]).nullish(),
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { prisma } from "../../db";
import { singleFilter } from "../../interfaces/filters";
@@ -14,7 +14,7 @@ import {
WidgetDomainSchema,
DashboardDefinitionSchema,
} from "./types";
import { z } from "zod";
import { z } from "zod/v4";
export class DashboardService {
/**
@@ -1,5 +1,5 @@
import { DashboardWidgetChartType, DashboardWidgetViews } from "@prisma/client";
import { z } from "zod";
import { z } from "zod/v4";
import { singleFilter } from "../../../";
export const BaseTimeSeriesChartConfig = z.object({});
@@ -28,6 +28,11 @@ export const BigNumberChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("NUMBER"),
});
export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
type: z.literal("HISTOGRAM"),
bins: z.number().int().min(1).max(100).optional().default(10),
});
// Define dimension schema
export const DimensionSchema = z.object({
field: z.string(),
@@ -47,6 +52,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
VerticalBarChartConfig,
PieChartConfig,
BigNumberChartConfig,
HistogramChartConfig,
]);
export const DashboardDefinitionWidgetWidgetSchema = z.object({
@@ -99,11 +105,11 @@ export const WidgetDomainSchema = z.object({
projectId: z.string().nullable(),
name: z.string(),
description: z.string(),
view: z.nativeEnum(DashboardWidgetViews),
view: z.enum(DashboardWidgetViews),
dimensions: z.array(DimensionSchema),
metrics: z.array(MetricSchema),
filters: z.array(singleFilter),
chartType: z.nativeEnum(DashboardWidgetChartType),
chartType: z.enum(DashboardWidgetChartType),
chartConfig: ChartConfigSchema,
owner: OwnerEnum,
});
@@ -112,11 +118,11 @@ export const WidgetDomainSchema = z.object({
export const CreateWidgetInputSchema = z.object({
name: z.string().min(1, "Widget name is required"),
description: z.string(),
view: z.nativeEnum(DashboardWidgetViews),
view: z.enum(DashboardWidgetViews),
dimensions: z.array(DimensionSchema),
metrics: z.array(MetricSchema),
filters: z.array(singleFilter),
chartType: z.nativeEnum(DashboardWidgetChartType),
chartType: z.enum(DashboardWidgetChartType),
chartConfig: ChartConfigSchema,
});
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { prisma } from "../../../db";
import { LangfuseNotFoundError } from "../../../errors";
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
@@ -90,7 +90,7 @@ export class DefaultEvalModelService {
const result = ZodModelConfig.safeParse(config.modelParams);
if (!result.success) {
errors.push(
...result.error.errors.map(
...result.error.issues.map(
(err) => `Model parameter error: ${err.message}`,
),
);
@@ -157,6 +157,12 @@ export class PromptService {
}
}
/**
* Lock the cache so reads will go to the database and not to Redis
*
* This is useful in order to return consistent data during the
* invalidation of the cache where we are looping through the relevant cache keys
*/
public async lockCache(
params: Pick<PromptParams, "projectId" | "promptName">,
): Promise<void> {
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { orderBy, singleFilter } from "../../..";
export const CreateTableViewPresetsInput = z.object({
@@ -29,13 +29,13 @@ export const sendBatchExportSuccessEmail = async ({
try {
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const htmlTemplate = render(
const htmlTemplate = await render(
BatchExportSuccessEmailTemplate({
receiverEmail,
downloadLink,
userName,
batchExportName,
})
}),
);
await mailer.sendMail({
@@ -61,7 +61,7 @@ export const sendMembershipInvitationEmail = async ({
try {
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
const htmlTemplate = render(
const htmlTemplate = await render(
MembershipInvitationTemplate({
invitedByUsername: inviterName,
invitedByUserEmail: inviterEmail,
@@ -68,7 +68,8 @@ export async function sendResetPasswordVerificationRequest(
) {
const { identifier, token, provider } = params as SendVerificationRequestParams & { token: string };
const transport = createTransport(provider.server);
const htmlTemplate = render(<ResetPasswordTemplate token={token} />);
const htmlTemplate = await render(<ResetPasswordTemplate token={token} />);
const result = await transport.sendMail({
to: identifier,
from: provider.from,
+1 -1
View File
@@ -1,4 +1,4 @@
import { type z } from "zod";
import { type z } from "zod/v4";
import { singleFilter, timeFilter } from "./interfaces/filters";
// to be sent to the server
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
export const applyScoreValidation = <T extends z.ZodType<any, any, any>>(
schema: T,
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const isPresent = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined && value !== "";
+3 -3
View File
@@ -1,4 +1,4 @@
import * as z from "zod";
import { z } from "zod/v4";
// to be used for Prisma JSON type
// @see: https://github.com/colinhacks/zod#json-type
@@ -24,7 +24,7 @@ type Json = Root | { [key: string]: JsonNested } | JsonNested[];
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
z.union([
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
z.record(z.string(), jsonSchemaNullable),
nestedLiteralSchema,
]),
);
@@ -33,7 +33,7 @@ export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([
z.array(jsonSchemaNullable),
z.record(jsonSchemaNullable),
z.record(z.string(), jsonSchemaNullable),
rootLiteralSchema,
]),
);
+2046 -2123
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.65.2",
"version": "3.69.0",
"private": true,
"license": "MIT",
"engines": {
@@ -35,10 +35,10 @@
"@headlessui/react": "2.1.9",
"@headlessui/tailwindcss": "0.2.1",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.3.4",
"@langchain/anthropic": "^0.3.8",
"@langchain/core": "^0.3.18",
"@langchain/openai": "^0.3.14",
"@hookform/resolvers": "^5.1.1",
"@langchain/anthropic": "^0.3.21",
"@langchain/core": "^0.3.57",
"@langchain/openai": "^0.5.12",
"@langfuse/ee": "workspace:*",
"@langfuse/shared": "workspace:*",
"@lezer/highlight": "^1.2.1",
@@ -120,10 +120,10 @@
"ioredis": "^5.4.1",
"ip-address": "^9.0.5",
"kysely": "^0.27.4",
"langchain": "^0.3.6",
"langchain": "^0.3.27",
"lodash": "^4.17.21",
"lucide-react": "^0.462.0",
"next": "^14.2.26",
"next": "^14.2.30",
"next-auth": "^4.24.11",
"next-query-params": "^5.0.1",
"next-themes": "^0.3.0",
@@ -137,7 +137,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "18.2.0",
"react-grid-layout": "^1.5.1",
"react-hook-form": "^7.51.5",
"react-hook-form": "^7.57.0",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
@@ -160,7 +160,7 @@
"uuid": "^9.0.1",
"vaul": "^1.1.2",
"vis-network": "^9.1.9",
"zod": "^3.24.4"
"zod": "^3.25.62"
},
"devDependencies": {
"@jedmao/location": "^3.0.0",
+8 -2
View File
@@ -1589,7 +1589,7 @@ paths:
"metrics": [ // Required. At least one metric must be provided
{
"measure": string, // What to measure, e.g. "count", "latency", "value"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95", "histogram"
}
],
"filters": [ // Optional. Default: []
@@ -1611,7 +1611,11 @@ paths:
"field": string, // Field to order by
"direction": string // "asc" or "desc"
}
]
],
"config": { // Optional. Query-specific configuration
"bins": number, // Optional. Number of bins for histogram (1-100), default: 10
"row_limit": number // Optional. Row limit for results (1-1000)
}
}
```
@@ -6349,6 +6353,8 @@ components:
and dimensions requested in the query.
Format varies based on the query parameters.
Histograms will return an array with [lower, upper, height] tuples.
required:
- data
PaginatedModels:
+1 -1
View File
@@ -1270,7 +1270,7 @@
{
"key": "query",
"value": "",
"description": "JSON string containing the query parameters with the following structure:\n```json\n{\n \"view\": string, // Required. One of \"traces\", \"observations\", \"scores-numeric\", \"scores-categorical\"\n \"dimensions\": [ // Optional. Default: []\n {\n \"field\": string // Field to group by, e.g. \"name\", \"userId\", \"sessionId\"\n }\n ],\n \"metrics\": [ // Required. At least one metric must be provided\n {\n \"measure\": string, // What to measure, e.g. \"count\", \"latency\", \"value\"\n \"aggregation\": string // How to aggregate, e.g. \"count\", \"sum\", \"avg\", \"p95\"\n }\n ],\n \"filters\": [ // Optional. Default: []\n {\n \"column\": string, // Column to filter on\n \"operator\": string, // Operator, e.g. \"=\", \">\", \"<\", \"contains\"\n \"value\": any, // Value to compare against\n \"type\": string, // Data type, e.g. \"string\", \"number\", \"stringObject\"\n \"key\": string // Required only when filtering on metadata\n }\n ],\n \"timeDimension\": { // Optional. Default: null. If provided, results will be grouped by time\n \"granularity\": string // One of \"minute\", \"hour\", \"day\", \"week\", \"month\", \"auto\"\n },\n \"fromTimestamp\": string, // Required. ISO datetime string for start of time range\n \"toTimestamp\": string, // Required. ISO datetime string for end of time range\n \"orderBy\": [ // Optional. Default: null\n {\n \"field\": string, // Field to order by\n \"direction\": string // \"asc\" or \"desc\"\n }\n ]\n}\n```"
"description": "JSON string containing the query parameters with the following structure:\n```json\n{\n \"view\": string, // Required. One of \"traces\", \"observations\", \"scores-numeric\", \"scores-categorical\"\n \"dimensions\": [ // Optional. Default: []\n {\n \"field\": string // Field to group by, e.g. \"name\", \"userId\", \"sessionId\"\n }\n ],\n \"metrics\": [ // Required. At least one metric must be provided\n {\n \"measure\": string, // What to measure, e.g. \"count\", \"latency\", \"value\"\n \"aggregation\": string // How to aggregate, e.g. \"count\", \"sum\", \"avg\", \"p95\", \"histogram\"\n }\n ],\n \"filters\": [ // Optional. Default: []\n {\n \"column\": string, // Column to filter on\n \"operator\": string, // Operator, e.g. \"=\", \">\", \"<\", \"contains\"\n \"value\": any, // Value to compare against\n \"type\": string, // Data type, e.g. \"string\", \"number\", \"stringObject\"\n \"key\": string // Required only when filtering on metadata\n }\n ],\n \"timeDimension\": { // Optional. Default: null. If provided, results will be grouped by time\n \"granularity\": string // One of \"minute\", \"hour\", \"day\", \"week\", \"month\", \"auto\"\n },\n \"fromTimestamp\": string, // Required. ISO datetime string for start of time range\n \"toTimestamp\": string, // Required. ISO datetime string for end of time range\n \"orderBy\": [ // Optional. Default: null\n {\n \"field\": string, // Field to order by\n \"direction\": string // \"asc\" or \"desc\"\n }\n ],\n \"config\": { // Optional. Query-specific configuration\n \"bins\": number, // Optional. Number of bins for histogram (1-100), default: 10\n \"row_limit\": number // Optional. Row limit for results (1-1000)\n }\n}\n```"
}
],
"variable": []
+1 -1
View File
@@ -395,7 +395,7 @@ describe("Authenticate API calls", () => {
// Parse should fail because the scope is missing
expect(() => {
OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
}).toThrow("invalid_union_discriminator");
}).toThrow("invalid_union");
// Auth should still succeed by falling back to Postgres
const verification = await new ApiAuthService(
@@ -7,7 +7,7 @@ import {
PostCommentsV1Response,
} from "@/src/features/public-api/types/comments";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod";
import { z } from "zod/v4";
import {
createObservationsCh,
createTracesCh,
@@ -101,7 +101,7 @@ describe("Create and get comments", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"too_small\",\"minimum\":1,\"type\":\"string\",\"inclusive\":true,\"exact\":false,\"message\":\"String must contain at least 1 character(s)\",\"path\":[\"content\"]}]}`,
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"origin\":\"string\",\"code\":\"too_small\",\"minimum\":1,\"inclusive\":true,\"path\":[\"content\"],\"message\":\"Too small: expected string to have >=1 characters\"}]}`,
);
}
});
@@ -124,7 +124,7 @@ describe("Create and get comments", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"too_big\",\"maximum\":3000,\"type\":\"string\",\"inclusive\":true,\"exact\":false,\"message\":\"String must contain at most 3000 character(s)\",\"path\":[\"content\"]}]}`,
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"origin\":\"string\",\"code\":\"too_big\",\"maximum\":3000,\"inclusive\":true,\"path\":[\"content\"],\"message\":\"Too big: expected string to have <=3000 characters\"}]}`,
);
}
});
@@ -302,7 +302,7 @@ describe("GET /api/public/comments API Endpoint", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"custom\",\"message\":\"objectType is required when objectId is provided\",\"path\":[\"objectType\"]}]}`,
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"custom\",\"path\":[\"objectType\"],\"message\":\"objectType is required when objectId is provided\"}]}`,
);
}
});
@@ -5,7 +5,7 @@ import {
makeAPICall,
} from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod";
import { z } from "zod/v4";
import { randomUUID } from "crypto";
import { Role } from "@langfuse/shared";
import {
@@ -16,7 +16,7 @@ import {
// Schema for membership response
const MembershipResponseSchema = z.object({
userId: z.string(),
role: z.nativeEnum(Role),
role: z.enum(Role),
email: z.string().email(),
name: z.string().nullable(),
});
@@ -399,4 +399,102 @@ describe("/api/public/metrics API Endpoint", () => {
expect(body).toHaveProperty("error");
expect(body.message).toMatch(/Invalid filter column/);
});
it("should handle histogram aggregation with custom bin count", async () => {
// Create test data with varying costs for histogram
const histogramTraceId = randomUUID();
// Create a trace for histogram testing
await createTracesCh([
createTrace({
id: histogramTraceId,
name: "histogram-test-trace",
project_id: projectId,
timestamp: now.getTime(),
metadata: { test: testMetadataValue },
}),
]);
// Create observations with varying costs to test histogram
const histogramObservations: ReturnType<typeof createObservation>[] = [];
const costValues = [
// Low cost cluster - 5 observations
0.001, 0.002, 0.003, 0.004, 0.005,
// Medium cost cluster - 5 observations
0.05, 0.06, 0.07, 0.08, 0.09,
// High cost cluster - 5 observations
0.5, 0.6, 0.7, 0.8, 0.9,
];
costValues.forEach((cost, index) => {
histogramObservations.push(
createObservation({
id: randomUUID(),
trace_id: histogramTraceId,
project_id: projectId,
name: `histogram-observation-${index}`,
start_time: now.getTime(),
total_cost: cost,
metadata: { test: testMetadataValue },
}),
);
});
await createObservationsCh(histogramObservations);
// Test histogram query with custom bin count
const histogramQuery = {
view: "observations",
dimensions: [],
metrics: [{ measure: "totalCost", aggregation: "histogram" }],
filters: [
{
column: "metadata",
operator: "contains",
key: "test",
value: testMetadataValue,
type: "stringObject",
},
],
timeDimension: null,
fromTimestamp: twoDaysAgo.toISOString(),
toTimestamp: tomorrow.toISOString(),
orderBy: null,
config: { bins: 15 },
};
// Make the API call
const response = await makeZodVerifiedAPICall(
GetMetricsV1Response,
"GET",
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(histogramQuery))}`,
);
// Validate response format
expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data).toHaveLength(1);
// Validate histogram data structure
const histogramData = response.body.data[0].histogram_totalCost as [
number,
number,
number,
][];
expect(Array.isArray(histogramData)).toBe(true);
expect(histogramData.length).toBeGreaterThan(0);
expect(histogramData.length).toBeLessThanOrEqual(15); // Should not exceed requested bins
// Verify histogram tuple structure [lower, upper, height]
histogramData.forEach((bin: [number, number, number]) => {
expect(Array.isArray(bin)).toBe(true);
expect(bin).toHaveLength(3);
const [lower, upper, height] = bin;
expect(typeof lower).toBe("number");
expect(typeof upper).toBe("number");
expect(typeof height).toBe("number");
expect(lower).toBeLessThanOrEqual(upper);
expect(height).toBeGreaterThanOrEqual(0);
});
});
});
@@ -5,7 +5,7 @@ import {
makeAPICall,
} from "@/src/__tests__/test-utils";
import { prisma } from "@langfuse/shared/src/db";
import { z } from "zod";
import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
createAndAddApiKeysToDb,
@@ -749,7 +749,7 @@ describe("Admin Organizations API", () => {
const OrganizationProjectSchema = z.object({
id: z.string(),
name: z.string(),
metadata: z.record(z.unknown()).nullable(),
metadata: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
@@ -4,7 +4,7 @@ import {
makeZodVerifiedAPICall,
makeAPICall,
} from "@/src/__tests__/test-utils";
import { z } from "zod";
import { z } from "zod/v4";
import {
createAndAddApiKeysToDb,
createBasicAuthHeader,
@@ -4,7 +4,7 @@ import {
makeZodVerifiedAPICall,
makeAPICall,
} from "@/src/__tests__/test-utils";
import { z } from "zod";
import { z } from "zod/v4";
import {
createAndAddApiKeysToDb,
createBasicAuthHeader,
@@ -19,7 +19,7 @@ import {
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
import { z } from "zod";
import { z } from "zod/v4";
import waitForExpect from "wait-for-expect";
describe("/api/public/scores API Endpoint", () => {
@@ -916,7 +916,7 @@ describe("/api/public/scores API Endpoint", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"received\":\"op\",\"code\":\"invalid_enum_value\",\"options\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid enum value. Expected '<' | '>' | '<=' | '>=' | '!=' | '=', received 'op'\"}]}`,
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"invalid_value\",\"values\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid option: expected one of \\\"<\\\"|\\\">\\\"|\\\"<=\\\"|\\\">=\\\"|\\\"!=\\\"|\\\"=\\\"\"}]}`,
);
}
});
@@ -935,7 +935,7 @@ describe("/api/public/scores API Endpoint", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"code":"invalid_type","expected":"number","received":"nan","path":["value"],"message":"Expected number, received nan"}]}',
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"expected":"number","code":"invalid_type","received":"NaN","path":["value"],"message":"Invalid input: expected number, received NaN"}]}',
);
}
});
@@ -15,7 +15,7 @@ import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
import { GetScoreResponseV2, GetScoresResponseV2 } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { v4 } from "uuid";
import { z } from "zod";
import { z } from "zod/v4";
describe("/api/public/v2/scores API Endpoint", () => {
describe("GET /api/public/v2/scores/:scoreId", () => {
@@ -811,7 +811,7 @@ describe("/api/public/v2/scores API Endpoint", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"received\":\"op\",\"code\":\"invalid_enum_value\",\"options\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid enum value. Expected '<' | '>' | '<=' | '>=' | '!=' | '=', received 'op'\"}]}`,
`API call did not return 200, returned status 400, body {\"message\":\"Invalid request data\",\"error\":[{\"code\":\"invalid_value\",\"values\":[\"<\",\">\",\"<=\",\">=\",\"!=\",\"=\"],\"path\":[\"operator\"],\"message\":\"Invalid option: expected one of \\\"<\\\"|\\\">\\\"|\\\"<=\\\"|\\\">=\\\"|\\\"!=\\\"|\\\"=\\\"\"}]}`,
);
}
});
@@ -830,7 +830,7 @@ describe("/api/public/v2/scores API Endpoint", () => {
);
} catch (error) {
expect((error as Error).message).toBe(
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"code":"invalid_type","expected":"number","received":"nan","path":["value"],"message":"Expected number, received nan"}]}',
'API call did not return 200, returned status 400, body {"message":"Invalid request data","error":[{"expected":"number","code":"invalid_type","received":"NaN","path":["value"],"message":"Invalid input: expected number, received NaN"}]}',
);
}
});
+343 -2
View File
@@ -7,6 +7,7 @@ import { LLMAdapter } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { decrypt } from "@langfuse/shared/encryption";
describe("llmApiKey.all RPC", () => {
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
@@ -18,29 +19,37 @@ describe("llmApiKey.all RPC", () => {
user: {
id: "user-1",
name: "Demo User",
canCreateOrganizations: true,
organizations: [
{
id: "seed-org-id",
role: "OWNER",
plan: "cloud:hobby",
cloudConfig: undefined,
name: "Test Organization",
metadata: {},
projects: [
{
id: projectId,
role: "ADMIN",
name: "Test Project",
deletedAt: null,
retentionDays: null,
metadata: {},
},
],
},
],
featureFlags: {
templateFlag: true,
excludeClickhouseRead: false,
},
admin: true,
},
environment: {} as any,
};
const ctx = createInnerTRPCContext({ session });
const ctx = createInnerTRPCContext({ session, headers: {} });
const caller = appRouter.createCaller({ ...ctx, prisma });
it("should create an llm api key", async () => {
@@ -123,6 +132,338 @@ describe("llmApiKey.all RPC", () => {
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
// response must not contain the secret key itself
expect(llmApiKeys[0]).not.toHaveProperty("secretKey");
const secretKey = llmApiKeys[0].secretKey;
expect(secretKey).toBeUndefined();
});
it("should create and update an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const customModels = ["fancy-gpt-3.5-turbo"];
const baseURL = "https://custom.openai.com/v1";
const withDefaultModels = false;
// Create initial key
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider,
adapter,
baseURL,
customModels,
withDefaultModels,
});
// Verify initial key
const initialKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(initialKeys.length).toBe(1);
expect(initialKeys[0].projectId).toBe(projectId);
expect(initialKeys[0].secretKey).not.toBeNull();
expect(initialKeys[0].secretKey).not.toEqual(secret);
expect(initialKeys[0].provider).toBe(provider);
expect(initialKeys[0].adapter).toBe(adapter);
expect(initialKeys[0].baseURL).toBe(baseURL);
expect(initialKeys[0].customModels).toEqual(customModels);
expect(initialKeys[0].withDefaultModels).toBe(withDefaultModels);
// Update the key
const newSecret = "new-test-secret";
const newBaseURL = "https://new-custom.openai.com/v1";
const newCustomModels = ["new-fancy-gpt-3.5-turbo"];
const newWithDefaultModels = true;
await caller.llmApiKey.update({
id: initialKeys[0].id,
projectId,
secretKey: newSecret,
provider,
adapter,
baseURL: newBaseURL,
customModels: newCustomModels,
withDefaultModels: newWithDefaultModels,
});
// Verify updated key
const updatedKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(updatedKeys.length).toBe(1);
expect(updatedKeys[0].projectId).toBe(projectId);
expect(updatedKeys[0].secretKey).not.toBeNull();
expect(updatedKeys[0].secretKey).not.toEqual(newSecret);
expect(updatedKeys[0].provider).toBe(provider); // Should not change
expect(updatedKeys[0].adapter).toBe(adapter); // Should not change
expect(updatedKeys[0].baseURL).toBe(newBaseURL);
expect(updatedKeys[0].customModels).toEqual(newCustomModels);
expect(updatedKeys[0].withDefaultModels).toBe(newWithDefaultModels);
});
it("should update only the secret key", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const customModels = ["fancy-gpt-3.5-turbo"];
const baseURL = "https://custom.openai.com/v1";
const withDefaultModels = false;
// Create initial key
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider,
adapter,
baseURL,
customModels,
withDefaultModels,
});
const initialKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
provider,
},
});
expect(initialKeys.length).toBe(1);
const initialDisplaySecretKey = initialKeys[0].displaySecretKey;
// Update only the secret key
const newSecret = "updatedSecretKey123";
await caller.llmApiKey.update({
id: initialKeys[0].id,
projectId,
secretKey: newSecret,
provider,
adapter,
});
// Verify updated key
const updatedKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
provider,
},
});
expect(updatedKeys.length).toBe(1);
expect(decrypt(updatedKeys[0].secretKey)).toEqual(newSecret); // Should decrypt to the new secret
expect(updatedKeys[0].displaySecretKey).not.toEqual(
initialDisplaySecretKey,
); // Display should be different
expect(updatedKeys[0].displaySecretKey).toEqual("...y123"); // Should match format with hyphens allowed
// Other fields should remain unchanged
expect(updatedKeys[0].baseURL).toBe(baseURL);
expect(updatedKeys[0].customModels).toEqual(customModels);
expect(updatedKeys[0].withDefaultModels).toBe(withDefaultModels);
expect(updatedKeys[0].provider).toBe(provider);
expect(updatedKeys[0].adapter).toBe(adapter);
});
it("should update only the extra headers", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const customModels = ["fancy-gpt-3.5-turbo"];
const baseURL = "https://custom.openai.com/v1";
const withDefaultModels = false;
const extraHeaders = {
"X-Custom-Header": "custom-value",
Authorization: "Bearer token123",
};
// Create initial key with extra headers
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider,
adapter,
baseURL,
customModels,
withDefaultModels,
extraHeaders,
});
const initialKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(initialKeys.length).toBe(1);
expect(initialKeys[0].extraHeaders).not.toBeNull();
expect(initialKeys[0].extraHeaderKeys).toEqual(Object.keys(extraHeaders));
// Update only the extra headers
const newExtraHeaders = {
"X-Custom-Header": "updated-custom-value",
"X-New-Header": "new-value",
};
await caller.llmApiKey.update({
id: initialKeys[0].id,
projectId,
provider,
adapter,
extraHeaders: newExtraHeaders,
});
// Verify updated key
const updatedKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(updatedKeys.length).toBe(1);
expect(updatedKeys[0].extraHeaders).not.toBeNull();
expect(updatedKeys[0].extraHeaders).not.toEqual(
initialKeys[0].extraHeaders,
); // Should be different
expect(updatedKeys[0].extraHeaderKeys).toEqual(
Object.keys(newExtraHeaders),
);
// Other fields should remain unchanged
expect(updatedKeys[0].secretKey).toEqual(initialKeys[0].secretKey); // Secret should be same
expect(updatedKeys[0].displaySecretKey).toEqual(
initialKeys[0].displaySecretKey,
); // Display should be same
expect(updatedKeys[0].baseURL).toBe(baseURL);
expect(updatedKeys[0].customModels).toEqual(customModels);
expect(updatedKeys[0].withDefaultModels).toBe(withDefaultModels);
expect(updatedKeys[0].provider).toBe(provider);
expect(updatedKeys[0].adapter).toBe(adapter);
});
it("should remove extra headers when updated with empty object", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const extraHeaders = {
"X-Custom-Header": "custom-value",
Authorization: "Bearer token123",
};
// Create initial key with extra headers
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider,
adapter,
extraHeaders,
});
const initialKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(initialKeys.length).toBe(1);
expect(initialKeys[0].extraHeaders).not.toBeNull();
expect(initialKeys[0].extraHeaderKeys).toEqual(Object.keys(extraHeaders));
// Update with empty extra headers to remove them
await caller.llmApiKey.update({
id: initialKeys[0].id,
projectId,
provider,
adapter,
extraHeaders: {},
});
// Verify updated key
const updatedKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(updatedKeys.length).toBe(1);
// Note: Current router logic doesn't actually clear headers when passing empty object
// because Prisma undefined means "don't update", not "set to null"
// The headers remain unchanged when an empty object is passed
expect(updatedKeys[0].extraHeaders).not.toBeNull();
expect(updatedKeys[0].extraHeaderKeys).not.toBeNull();
// Other fields should remain unchanged
expect(updatedKeys[0].secretKey).toEqual(initialKeys[0].secretKey);
expect(updatedKeys[0].displaySecretKey).toEqual(
initialKeys[0].displaySecretKey,
);
expect(updatedKeys[0].provider).toBe(provider);
expect(updatedKeys[0].adapter).toBe(adapter);
});
it("should partially update extra headers preserving existing values for empty inputs", async () => {
const secret = "test-secret";
const provider = "openai";
const adapter = LLMAdapter.OpenAI;
const extraHeaders = {
"X-Custom-Header": "custom-value",
Authorization: "Bearer token123",
"X-Another-Header": "another-value",
};
// Create initial key with extra headers
await caller.llmApiKey.create({
projectId,
secretKey: secret,
provider,
adapter,
extraHeaders,
});
const initialKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(initialKeys.length).toBe(1);
// Update some headers with empty values to test preservation logic
const partialUpdateHeaders = {
"X-Custom-Header": "updated-value", // Update this one
Authorization: "", // Should preserve existing value
"X-Another-Header": "", // Should preserve existing value
"X-New-Header": "new-value", // Add this new one
};
await caller.llmApiKey.update({
id: initialKeys[0].id,
projectId,
provider,
adapter,
extraHeaders: partialUpdateHeaders,
});
// Verify updated key
const updatedKeys = await prisma.llmApiKeys.findMany({
where: {
projectId,
},
});
expect(updatedKeys.length).toBe(1);
expect(updatedKeys[0].extraHeaders).not.toBeNull();
// Should have 4 headers: 3 original + 1 new
expect(updatedKeys[0].extraHeaderKeys).toHaveLength(4);
expect(updatedKeys[0].extraHeaderKeys).toContain("X-Custom-Header");
expect(updatedKeys[0].extraHeaderKeys).toContain("Authorization");
expect(updatedKeys[0].extraHeaderKeys).toContain("X-Another-Header");
expect(updatedKeys[0].extraHeaderKeys).toContain("X-New-Header");
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { z } from "zod";
import { z } from "zod/v4";
import { makeZodVerifiedAPICallSilent } from "@/src/__tests__/test-utils";
import { env } from "@/src/env.mjs";
@@ -3349,6 +3349,132 @@ describe("queryBuilder", () => {
expect(result.data[0].name).toBe("observation-basic");
expect(result.data[0].count_count).toBe("1");
});
it("should generate histogram with custom bin count for cost distribution", async () => {
// Setup
const projectId = randomUUID();
// Create traces with observations that have different costs
const traces = [];
const observations = [];
// Create trace for cost distribution test
const trace = createTrace({
project_id: projectId,
name: "cost-distribution-trace",
environment: "default",
timestamp: new Date().getTime(),
});
traces.push(trace);
// Create observations with varying costs to test histogram with custom bins
// Generate 30 observations with costs ranging from $0.001 to $1.00
const costValues = [
// Low cost cluster ($0.001-$0.01) - 10 observations
...Array.from({ length: 10 }, (_, i) => 0.001 + i * 0.001),
// Medium cost cluster ($0.05-$0.20) - 10 observations
...Array.from({ length: 10 }, (_, i) => 0.05 + i * 0.015),
// High cost cluster ($0.50-$1.00) - 10 observations
...Array.from({ length: 10 }, (_, i) => 0.5 + i * 0.05),
];
costValues.forEach((cost, index) => {
observations.push(
createObservation({
project_id: projectId,
trace_id: trace.id,
type: "generation",
name: `cost-observation-${index}`,
provided_model_name: "gpt-4",
environment: "default",
start_time: new Date().getTime(),
end_time: new Date().getTime() + 1000,
total_cost: cost,
}),
);
});
await createTracesCh(traces);
await createObservationsCh(observations);
// Test histogram with custom bin count (20 bins)
const customBinHistogramQuery: QueryType = {
view: "observations",
dimensions: [],
metrics: [
{
measure: "totalCost",
aggregation: "histogram",
},
],
filters: [
{
column: "type",
operator: "=",
value: "generation",
type: "string",
},
],
timeDimension: null,
fromTimestamp: new Date(
new Date().setDate(new Date().getDate() - 1),
).toISOString(),
toTimestamp: new Date(
new Date().setDate(new Date().getDate() + 1),
).toISOString(),
orderBy: null,
chartConfig: { type: "HISTOGRAM", bins: 20 }, // Custom bin count
};
// Execute histogram query with custom bins
const queryBuilder = new QueryBuilder(customBinHistogramQuery.chartConfig);
const { query: compiledQuery, parameters } = queryBuilder.build(
customBinHistogramQuery,
projectId,
);
// Verify the generated SQL contains histogram function with custom bins
expect(compiledQuery).toContain("histogram(20)");
expect(compiledQuery).toContain("total_cost");
const result = await (
await clickhouseClient().query({
query: compiledQuery,
query_params: parameters,
})
).json();
// Assert histogram results with custom bins
expect(result.data).toHaveLength(1);
const histogramData = result.data[0].histogram_totalCost;
// ClickHouse histogram returns array of tuples [lower, upper, height]
expect(Array.isArray(histogramData)).toBe(true);
expect(histogramData.length).toBeGreaterThan(0);
expect(histogramData.length).toBeLessThanOrEqual(20); // Should not exceed requested bins
// Verify histogram tuple structure and cost ranges
histogramData.forEach((bin: [number, number, number]) => {
expect(Array.isArray(bin)).toBe(true);
expect(bin).toHaveLength(3);
const [lower, upper, height] = bin;
expect(typeof lower).toBe("number");
expect(typeof upper).toBe("number");
expect(typeof height).toBe("number");
expect(lower).toBeLessThan(upper);
expect(height).toBeGreaterThan(0);
// Cost values should be in expected range
expect(lower).toBeGreaterThanOrEqual(0);
expect(upper).toBeLessThanOrEqual(1.1); // Allow some margin for ClickHouse binning
});
// Verify total count matches our data
const totalCount = histogramData.reduce(
(sum: number, bin: [number, number, number]) => sum + bin[2],
0,
);
expect(totalCount).toBe(30); // Should match our 30 observations
});
});
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ import {
clickhouseClient,
createBasicAuthHeader,
} from "@langfuse/shared/src/server";
import { type z } from "zod";
import { type z } from "zod/v4";
export const pruneDatabase = async () => {
if (!env.DATABASE_URL.includes("localhost:5432")) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { paginationZod, parseJsonPrioritised } from "@langfuse/shared";
import { ZodError } from "zod";
import { ZodError } from "zod/v4";
// Create test cases
describe("Pagination Zod Schema", () => {
+10 -9
View File
@@ -16,7 +16,7 @@ import {
Bot,
} from "lucide-react";
import { cva } from "class-variance-authority";
import { ObservationType } from "@langfuse/shared";
import { type ObservationType } from "@langfuse/shared";
import { cn } from "@/src/utils/tailwind";
export type LangfuseItemType =
@@ -33,11 +33,11 @@ export type LangfuseItemType =
| "EVALUATOR"
| "RUNNING_EVALUATOR";
const iconMap: Record<LangfuseItemType, React.ElementType> = {
const iconMap = {
TRACE: ListTree,
[ObservationType.GENERATION]: Fan,
[ObservationType.EVENT]: CircleDot,
[ObservationType.SPAN]: MoveHorizontal,
GENERATION: Fan,
EVENT: CircleDot,
SPAN: MoveHorizontal,
SESSION: Clock,
USER: User,
QUEUE_ITEM: ClipboardPen,
@@ -54,9 +54,9 @@ const iconVariants = cva(cn("h-4 w-4"), {
variants: {
type: {
TRACE: "text-dark-green",
[ObservationType.GENERATION]: "text-muted-magenta",
[ObservationType.EVENT]: "text-muted-green",
[ObservationType.SPAN]: "text-muted-blue",
GENERATION: "text-muted-magenta",
EVENT: "text-muted-green",
SPAN: "text-muted-blue",
SESSION: "text-primary-accent",
USER: "text-primary-accent",
QUEUE_ITEM: "text-primary-accent",
@@ -91,7 +91,8 @@ export function ItemBadge({
className,
);
const label = type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();
const label =
String(type).charAt(0).toUpperCase() + String(type).slice(1).toLowerCase();
return (
<Badge
+1 -1
View File
@@ -61,7 +61,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
const hasEnabledModelSetting = Object.keys(modelParams).some(
(key) =>
!["adapter", "provider", "model"].includes(key) &&
modelParams[key as keyof typeof modelParams].enabled === true,
modelParams[key as keyof typeof modelParams].enabled,
);
if (hasEnabledModelSetting) {
+1 -1
View File
@@ -54,7 +54,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
? // self-host plan
// TODO: clean up to use planLabels in packages/shared/src/features/entitlements/plans.ts
{
short: "EE",
short: plan === "self-hosted:pro" ? "Pro" : "EE",
long: planLabels[plan],
}
: // no plan, oss
+1 -1
View File
@@ -117,7 +117,7 @@ export function DeleteButton({
</PopoverTrigger>
<PopoverContent onClick={(e) => e.stopPropagation()}>
<h2 className="text-md mb-3 font-semibold">Please confirm</h2>
<p className="mb-3 text-sm">
<p className="mb-3 max-w-72 text-sm">
{customDeletePrompt ??
`This action cannot be undone and removes all the data associated with
this ${entityToDeleteName}.`}

Some files were not shown because too many files have changed in this diff Show More