Compare commits

...
68 Commits
Author SHA1 Message Date
steffen911 65118f5f3a chore: release v3.67.1-2 2025-06-11 17:53:31 +02:00
steffen911 6ab82c539a chore: release v3.67.1-1 2025-06-11 17:51:38 +02:00
steffen911 20a6a707fb chore: linting 2025-06-11 17:51:24 +02:00
steffen911 99880810fd chore: release v3.67.1-0 2025-06-11 17:49:00 +02:00
steffen911 fbfa871ed7 chore: skip queue metric collection 2025-06-11 17:38:33 +02:00
steffen911 46ae185dcc chore: lint 2025-06-11 17:07:57 +02:00
steffen911 d4c4de7401 chore: remove redundent getQueue 2025-06-11 17:05:21 +02:00
steffen911 8a7b32e925 chore: patch 2025-06-11 16:59:55 +02:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2c5977cb4c Update packages/shared/src/server/redis/ingestionQueue.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-11 16:59:28 +02:00
steffen911 1affa965d1 feat: allow sharded ingestion queue 2025-06-11 16:55:03 +02:00
steffen911 150afecd29 chore: remove redis cluster testing script 2025-06-11 14:13:08 +02:00
steffen911 78c1c89625 chore: cleanup 2025-06-11 14:09:31 +02:00
steffen911 3fa06c8365 chore: secret 2025-06-11 14:01:26 +02:00
Steffen SchmitzandGitHub 91b1fa5848 Merge branch 'main' into steffen/lfe-3185-redis-cluster-mode 2025-06-11 13:58:00 +02:00
steffen911 4f5671429e chore: update example config 2025-06-11 13:57:50 +02:00
steffen911 5d0fa46657 chore: optimize setup for ci 2025-06-11 13:55:05 +02:00
steffen911 8d1a2ff0e7 chore: add ioredis debug logs for tests 2025-06-11 13:07:13 +02: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
steffen911 06255bd325 chore: add aws specific dns lookup 2025-06-11 11:06:47 +02: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
steffen911 cbf73a95a5 chore: execute test flush on all nodes 2025-06-10 18:25:18 +02:00
Steffen SchmitzandGitHub ebfeef32aa Merge branch 'main' into steffen/lfe-3185-redis-cluster-mode 2025-06-10 17:56:45 +02:00
steffen911 74dddbae00 chore: tty to skip model match cache 2025-06-10 17:56:33 +02: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
steffen911 603837ad3a chore: lint 2025-06-10 15:56:16 +02:00
Steffen SchmitzandGitHub 2d3f339f12 Merge branch 'main' into steffen/lfe-3185-redis-cluster-mode 2025-06-10 15:44:30 +02:00
steffen911 02605eb268 chore: update node config 2025-06-10 15:44:16 +02:00
steffen911 4eed2d7000 chore: lint 2025-06-10 15:35:59 +02:00
Hassieb PakzadandGitHub 4cf5aa03ec fix(trpc-prompts-api): handle prompt not found case on setLabels (#7216) 2025-06-10 13:00:23 +00:00
steffen911 9e730e3c61 chore: include queue name into prefix 2025-06-10 14:52:41 +02:00
steffen911 ba7c7b9526 chore: remove experimental queue length metrics flag 2025-06-10 14:23:59 +02:00
steffen911 8b1b389fc0 chore: remove unused env var declarations 2025-06-10 14:23:08 +02:00
steffen911 f7fc206bd0 chore: make cluster config compile 2025-06-10 14:14:40 +02:00
steffen911 dd2b1f697d chore: add redis-cluster docker compose setup 2025-06-10 14:07:09 +02:00
Steffen SchmitzandGitHub 7c799444d0 Merge branch 'main' into steffen/lfe-3185-redis-cluster-mode 2025-06-10 13:35:20 +02:00
steffen911 8afdb8db97 chore: fix compilation problems 2025-06-10 13:35:09 +02:00
Steffen SchmitzandGitHub 922eafd4f6 chore: refactor chart display names into single util (#7209) 2025-06-10 07:29:06 +00:00
steffen911 bc4acd453c Merge branch 'main' of https://github.com/langfuse/langfuse into steffen/lfe-3185-redis-cluster-mode 2025-06-10 09:26:01 +02: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
steffen911 64d5ebfa70 feat: create outline for redis cluster mode setup 2025-06-05 17:55:02 +02: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
166 changed files with 6440 additions and 4388 deletions
+84
View File
@@ -0,0 +1,84 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-east-1
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-east-1
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="bitnami"
REDIS_CLUSTER_ENABLED="true"
REDIS_CLUSTER_NODES="127.0.0.1:6370,127.0.0.1:6371,127.0.0.1:6372,127.0.0.1:6373,127.0.0.1:6374,127.0.0.1:6375"
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT=8
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
+4
View File
@@ -175,6 +175,10 @@ OTEL_SERVICE_NAME="langfuse"
# REDIS_CONNECTION_STRING=
# REDIS_ENABLE_AUTO_PIPELINING=
# Redis Cluster configuration (optional)
# REDIS_CLUSTER_ENABLED=false
# REDIS_CLUSTER_NODES=redis-node1:6379,redis-node2:6379,redis-node3:6379
# Cache configuration
# LANGFUSE_CACHE_API_KEY_ENABLED=
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
+11 -11
View File
@@ -171,12 +171,12 @@ jobs:
needs:
- pre-job
if: needs.pre-job.outputs.should_skip != 'true'
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -208,8 +208,8 @@ jobs:
pnpm install
- name: Load default env
run: |
cp .env.dev${{ matrix.blob-provider }}.example .env
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
cp .env.dev${{ matrix.deploy-mode }}.example .env
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.deploy-mode }}.example > .env
echo "LANGFUSE_INGESTION_QUEUE_DELAY_MS=1" >> .env
echo "LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=1" >> .env
echo "LANGFUSE_TRACE_DELETE_CONCURRENCY=100" >> .env
@@ -217,7 +217,7 @@ jobs:
echo "LANGFUSE_EE_LICENSE_KEY=langfuse_ee_test" >> .env
- name: Run dev containers
run: |
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
docker compose -f docker-compose.dev${{ matrix.deploy-mode }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
env:
@@ -250,12 +250,12 @@ jobs:
needs:
- pre-job
if: needs.pre-job.outputs.should_skip != 'true'
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.deploy-mode }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -287,12 +287,12 @@ jobs:
which migrate
- name: Load default env
run: |
cp .env.dev${{ matrix.blob-provider }}.example .env
cp .env.dev${{ matrix.blob-provider }}.example web/.env
cp .env.dev${{ matrix.blob-provider }}.example worker/.env
cp .env.dev${{ matrix.deploy-mode }}.example .env
cp .env.dev${{ matrix.deploy-mode }}.example web/.env
cp .env.dev${{ matrix.deploy-mode }}.example worker/.env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
docker compose -f docker-compose.dev${{ matrix.deploy-mode }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Ensure no unhealthy status
+1
View File
@@ -39,6 +39,7 @@ yarn-error.log*
.env*
!.env.dev.example
!.env.dev-azure.example
!.env.dev-redis-cluster.example
!.env.prod.example
# vercel
+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()
+146
View File
@@ -0,0 +1,146 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.3
user: "101:101"
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- 127.0.0.1:8123:8123
- 127.0.0.1:9000:9000
depends_on:
- postgres
minio:
image: minio/minio
entrypoint: sh
# create the 'langfuse' bucket before starting the service
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ACCESS_KEY: minio
MINIO_SECRET_KEY: miniosecret
ports:
- 127.0.0.1:9090:9000
- 127.0.0.1:9091:9001
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
command: ["postgres", "-c", "log_statement=all"]
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
ports:
- 127.0.0.1:5432:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
# Redis Cluster. Requires a Unix host to work for network_mode: host. I.e. tests will fail on windows and mac with this setup.
redis-node-0:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-0:/bitnami/redis/data
environment:
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6370
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-1:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-1:/bitnami/redis/data
environment:
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6371
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-2:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-2:/bitnami/redis/data
environment:
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6372
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-3:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-3:/bitnami/redis/data
environment:
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6373
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-4:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-4:/bitnami/redis/data
environment:
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6374
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
redis-node-5:
image: docker.io/bitnami/redis-cluster:8.0
network_mode: host
volumes:
- redis-cluster_data-5:/bitnami/redis/data
depends_on:
- redis-node-0
- redis-node-1
- redis-node-2
- redis-node-3
- redis-node-4
environment:
REDISCLI_AUTH: bitnami
REDIS_CLUSTER_REPLICAS: 1
REDIS_PASSWORD: bitnami
REDIS_PORT_NUMBER: 6375
REDIS_NODES: 127.0.0.1:6370 127.0.0.1:6371 127.0.0.1:6372 127.0.0.1:6373 127.0.0.1:6374 127.0.0.1:6375
REDIS_CLUSTER_CREATOR: yes
volumes:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_minio_data:
driver: local
redis-cluster_data-0:
driver: local
redis-cluster_data-1:
driver: local
redis-cluster_data-2:
driver: local
redis-cluster_data-3:
driver: local
redis-cluster_data-4:
driver: local
redis-cluster_data-5:
driver: local
+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}
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.65.3",
"version": "3.67.1-2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+8 -8
View File
@@ -62,12 +62,12 @@
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.11.1",
"@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",
@@ -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",
+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 {
+7
View File
@@ -24,6 +24,9 @@ const EnvSchema = z.object({
REDIS_TLS_CERT_PATH: z.string().optional(),
REDIS_TLS_KEY_PATH: z.string().optional(),
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
// Redis Cluster Configuration
REDIS_CLUSTER_ENABLED: z.enum(["true", "false"]).default("false"),
REDIS_CLUSTER_NODES: z.string().optional(),
ENCRYPTION_KEY: z
.string()
.length(
@@ -45,6 +48,10 @@ const EnvSchema = z.object({
.number()
.nonnegative()
.default(15_000),
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce
.number()
.positive()
.default(1),
SALT: z.string().optional(), // used by components imported by web package
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
@@ -1,7 +1,7 @@
import z from "zod";
import { singleFilter } from "../../interfaces/filters";
import { orderBy } from "../../interfaces/orderBy";
import { BatchExportTableName } from "../batchExport/types";
import { BatchTableNames } from "../../interfaces/tableNames";
/* eslint-disable no-unused-vars */
export enum BatchActionType {
@@ -29,11 +29,11 @@ export const CreateBatchActionSchema = z.object({
actionId: ActionIdSchema,
targetId: z.string().optional(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
});
export const GetIsBatchActionInProgressSchema = z.object({
projectId: z.string(),
actionId: ActionIdSchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
});
@@ -4,6 +4,7 @@ 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.nativeEnum(BatchTableNames),
filter: z.array(singleFilter).nullable(),
orderBy,
limit: z.number().optional(),
@@ -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
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";
@@ -0,0 +1,12 @@
/**
* 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",
}
@@ -229,10 +229,13 @@ export const processEventBatch = async (
throw new Error("Redis not initialized, aborting event processing");
}
const queue = IngestionQueue.getInstance();
await Promise.all(
Object.keys(sortedBatchByEventBodyId).map(async (id) =>
queue
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
const eventData = sortedBatchByEventBodyId[id];
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
const queue = IngestionQueue.getInstance(shardingKey);
return queue
? queue.add(
QueueJobs.IngestionJob,
{
@@ -241,13 +244,13 @@ export const processEventBatch = async (
name: QueueJobs.IngestionJob as const,
payload: {
data: {
type: sortedBatchByEventBodyId[id].type,
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
fileKey: sortedBatchByEventBodyId[id].key,
type: eventData.type,
eventBodyId: eventData.eventBodyId,
fileKey: eventData.key,
skipS3List:
source === "otel" &&
getClickhouseEntityType(
sortedBatchByEventBodyId[id].type,
eventData.type,
) === "observation",
},
authCheck: authCheck as {
@@ -261,8 +264,8 @@ export const processEventBatch = async (
},
{ delay: getDelay(delay) },
)
: Promise.reject("Failed to instantiate queue"),
),
: Promise.reject("Failed to instantiate queue");
}),
);
return aggregateBatchResult(
@@ -16,7 +16,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 +219,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
@@ -5,7 +5,7 @@ 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({
@@ -76,7 +76,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("score-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -85,7 +85,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("trace-delete"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -94,7 +94,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
actionId: z.literal("trace-add-to-annotation-queue"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.nativeEnum(BatchExportTableName),
tableName: z.nativeEnum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.nativeEnum(BatchActionType),
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class BatchActionQueue {
@@ -23,6 +27,7 @@ export class BatchActionQueue {
QueueName.BatchActionQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.BatchActionQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class BatchExportQueue {
@@ -22,6 +22,7 @@ export class BatchExportQueue {
QueueName.BatchExport,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.BatchExport),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class BlobStorageIntegrationProcessingQueue {
@@ -18,7 +18,8 @@ export class BlobStorageIntegrationProcessingQueue {
BlobStorageIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.BlobStorageIntegrationProcessingQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.BlobStorageIntegrationProcessingQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class BlobStorageIntegrationQueue {
@@ -19,6 +23,7 @@ export class BlobStorageIntegrationQueue {
BlobStorageIntegrationQueue.instance = newRedis
? new Queue(QueueName.BlobStorageIntegrationQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.BlobStorageIntegrationQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,7 +1,7 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class CloudUsageMeteringQueue {
@@ -24,6 +24,7 @@ export class CloudUsageMeteringQueue {
CloudUsageMeteringQueue.instance = newRedis
? new Queue(QueueName.CloudUsageMeteringQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.CloudUsageMeteringQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
import { env } from "../../env";
@@ -23,7 +23,8 @@ export class CoreDataS3ExportQueue {
CoreDataS3ExportQueue.instance = newRedis
? new Queue(QueueName.CoreDataS3ExportQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.CoreDataS3ExportQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class CreateEvalQueue {
@@ -23,6 +23,7 @@ export class CreateEvalQueue {
QueueName.CreateEvalQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.CreateEvalQueue),
defaultJobOptions: {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 100_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class DataRetentionProcessingQueue {
@@ -18,7 +18,8 @@ export class DataRetentionProcessingQueue {
DataRetentionProcessingQueue.instance = newRedis
? new Queue(QueueName.DataRetentionProcessingQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionProcessingQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class DataRetentionQueue {
@@ -18,7 +18,8 @@ export class DataRetentionQueue {
DataRetentionQueue.instance = newRedis
? new Queue(QueueName.DataRetentionQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class DatasetRunItemUpsertQueue {
@@ -24,6 +24,7 @@ export class DatasetRunItemUpsertQueue {
QueueName.DatasetRunItemUpsert,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.DatasetRunItemUpsert),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class DeadLetterRetryQueue {
@@ -18,7 +18,8 @@ export class DeadLetterRetryQueue {
DeadLetterRetryQueue.instance = newRedis
? new Queue(QueueName.DeadLetterRetryQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.DeadLetterRetryQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,7 +1,7 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
export class EvalExecutionQueue {
private static instance: Queue<
@@ -23,6 +23,7 @@ export class EvalExecutionQueue {
QueueName.EvaluationExecution,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.EvaluationExecution),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
@@ -1,7 +1,7 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
export class ExperimentCreateQueue {
private static instance: Queue<
@@ -23,6 +23,7 @@ export class ExperimentCreateQueue {
QueueName.ExperimentCreate,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.ExperimentCreate),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
+4 -2
View File
@@ -5,7 +5,7 @@ import { CloudUsageMeteringQueue } from "./cloudUsageMeteringQueue";
import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
import { EvalExecutionQueue } from "./evalExecutionQueue";
import { ExperimentCreateQueue } from "./experimentCreateQueue";
import { IngestionQueue, SecondaryIngestionQueue } from "./ingestionQueue";
import { SecondaryIngestionQueue } from "./ingestionQueue";
import { TraceUpsertQueue } from "./traceUpsert";
import { TraceDeleteQueue } from "./traceDelete";
import { ProjectDeleteQueue } from "./projectDelete";
@@ -39,7 +39,9 @@ export function getQueue(queueName: QueueName): Queue | null {
case QueueName.TraceDelete:
return TraceDeleteQueue.getInstance();
case QueueName.IngestionQueue:
return IngestionQueue.getInstance();
// IngestionQueue is sharded and requires a sharding key
// Use IngestionQueue.getInstance(shardingKey) directly instead
return null;
case QueueName.ProjectDelete:
return ProjectDeleteQueue.getInstance();
case QueueName.PostHogIntegrationQueue:
@@ -1,46 +1,69 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
import { getShardIndex } from "./sharding";
import { env } from "../../env";
export class IngestionQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.IngestionQueue]
> | null = null;
private static instances: Map<
number,
Queue<TQueueJobTypes[QueueName.IngestionQueue]> | null
> = new Map();
public static getInstance(): Queue<
TQueueJobTypes[QueueName.IngestionQueue]
> | null {
if (IngestionQueue.instance) return IngestionQueue.instance;
public static getShardNames() {
return Array.from(
{ length: env.LANGFUSE_INGESTION_QUEUE_SHARD_COUNT },
(_, i) => `${QueueName.IngestionQueue}${i > 0 ? `-${i}` : ""}`,
);
}
public static getInstance(
shardingKey: string,
): Queue<TQueueJobTypes[QueueName.IngestionQueue]> | null {
const shardIndex =
env.REDIS_CLUSTER_ENABLED === "true"
? getShardIndex(shardingKey, env.LANGFUSE_INGESTION_QUEUE_SHARD_COUNT)
: 0;
// Check if we already have an instance for this shard
if (IngestionQueue.instances.has(shardIndex)) {
return IngestionQueue.instances.get(shardIndex) || null;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
IngestionQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.IngestionQueue]>(
QueueName.IngestionQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
const name = `${QueueName.IngestionQueue}${shardIndex > 0 ? `-${shardIndex}` : ""}`;
const queueInstance = newRedis
? new Queue<TQueueJobTypes[QueueName.IngestionQueue]>(name, {
connection: newRedis,
prefix: getQueuePrefix(name),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
)
})
: null;
IngestionQueue.instance?.on("error", (err) => {
logger.error("IngestionQueue error", err);
queueInstance?.on("error", (err) => {
logger.error(`IngestionQueue shard ${shardIndex} error`, err);
});
return IngestionQueue.instance;
IngestionQueue.instances.set(shardIndex, queueInstance);
return queueInstance;
}
}
@@ -65,6 +88,7 @@ export class SecondaryIngestionQueue {
QueueName.IngestionSecondaryQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.IngestionSecondaryQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
import { env } from "../../env";
@@ -23,7 +23,8 @@ export class MeteringDataPostgresExportQueue {
MeteringDataPostgresExportQueue.instance = newRedis
? new Queue(QueueName.MeteringDataPostgresExportQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.MeteringDataPostgresExportQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationProcessingQueue {
@@ -18,7 +18,8 @@ export class PostHogIntegrationProcessingQueue {
PostHogIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationProcessingQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationProcessingQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -1,6 +1,6 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationQueue {
@@ -18,7 +18,8 @@ export class PostHogIntegrationQueue {
PostHogIntegrationQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationQueue, {
connection: newRedis,
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class ProjectDeleteQueue {
@@ -23,6 +23,7 @@ export class ProjectDeleteQueue {
QueueName.ProjectDelete,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.ProjectDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
+105 -18
View File
@@ -1,4 +1,4 @@
import Redis, { RedisOptions } from "ioredis";
import Redis, { RedisOptions, Cluster, ClusterOptions } from "ioredis";
import fs from "fs";
import { env } from "../../env";
import { logger } from "../logger";
@@ -24,26 +24,99 @@ export const redisQueueRetryOptions: Partial<RedisOptions> = {
},
};
/**
* Parse Redis cluster nodes from environment variable
* Format: "host1:port1,host2:port2,host3:port3"
*/
const parseClusterNodes = (
nodesString: string,
): Array<{ host: string; port: number }> => {
return nodesString.split(",").map((node) => {
const [host, port] = node.trim().split(":");
if (!host || !port) {
throw new Error(
`Invalid cluster node format: ${node}. Expected format: host:port`,
);
}
return { host, port: parseInt(port, 10) };
});
};
const createRedisClusterInstance = (
additionalOptions: Partial<RedisOptions> = {},
): Cluster | null => {
if (!env.REDIS_CLUSTER_NODES) {
logger.error(
"REDIS_CLUSTER_NODES is required when REDIS_CLUSTER_ENABLED is true",
);
return null;
}
const nodes = parseClusterNodes(env.REDIS_CLUSTER_NODES);
const tlsOptions =
env.REDIS_TLS_ENABLED === "true"
? {
tls: {
ca: env.REDIS_TLS_CA_PATH
? fs.readFileSync(env.REDIS_TLS_CA_PATH)
: undefined,
cert: env.REDIS_TLS_CERT_PATH
? fs.readFileSync(env.REDIS_TLS_CERT_PATH)
: undefined,
key: env.REDIS_TLS_KEY_PATH
? fs.readFileSync(env.REDIS_TLS_KEY_PATH)
: undefined,
},
}
: {};
const clusterOptions: ClusterOptions = {
// Return incoming addresses as-is - required for AWS ElastiCache Certificate resolution
dnsLookup: (address, callback) => {
callback(null, address);
},
redisOptions: {
password: env.REDIS_AUTH || undefined,
...defaultRedisOptions,
...additionalOptions,
...tlsOptions,
},
// Retry configuration for cluster
retryDelayOnFailover: 100,
};
const cluster = new Cluster(nodes, clusterOptions);
cluster.on("error", (error) => {
logger.error("Redis cluster error", error);
});
return cluster;
};
export const createNewRedisInstance = (
additionalOptions: Partial<RedisOptions> = {},
) => {
const tlsEnabled = env.REDIS_TLS_ENABLED === "true";
): Redis | Cluster | null => {
if (env.REDIS_CLUSTER_ENABLED === "true") {
return createRedisClusterInstance(additionalOptions);
}
const tlsOptions = tlsEnabled
? {
tls: {
ca: env.REDIS_TLS_CA_PATH
? fs.readFileSync(env.REDIS_TLS_CA_PATH)
: undefined,
cert: env.REDIS_TLS_CERT_PATH
? fs.readFileSync(env.REDIS_TLS_CERT_PATH)
: undefined,
key: env.REDIS_TLS_KEY_PATH
? fs.readFileSync(env.REDIS_TLS_KEY_PATH)
: undefined,
},
}
: {};
const tlsOptions =
env.REDIS_TLS_ENABLED === "true"
? {
tls: {
ca: env.REDIS_TLS_CA_PATH
? fs.readFileSync(env.REDIS_TLS_CA_PATH)
: undefined,
cert: env.REDIS_TLS_CERT_PATH
? fs.readFileSync(env.REDIS_TLS_CERT_PATH)
: undefined,
key: env.REDIS_TLS_KEY_PATH
? fs.readFileSync(env.REDIS_TLS_KEY_PATH)
: undefined,
},
}
: {};
const instance = env.REDIS_CONNECTION_STRING
? new Redis(env.REDIS_CONNECTION_STRING, {
@@ -69,6 +142,20 @@ export const createNewRedisInstance = (
return instance;
};
/**
* Get the queue prefix for BullMQ cluster compatibility
* In cluster mode, uses hash tags to ensure queue keys are on the same node
* In single-node mode, returns undefined (no prefix needed)
*/
export const getQueuePrefix = (queueName: string): string | undefined => {
if (env.REDIS_CLUSTER_ENABLED === "true") {
// Use hash tags for Redis cluster compatibility
// This ensures all keys for a queue are placed on the same hash slot
return `{${queueName}}`;
}
return undefined;
};
const createRedisClient = () => {
try {
return createNewRedisInstance();
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class ScoreDeleteQueue {
@@ -15,8 +15,11 @@ export class ScoreDeleteQueue {
});
ScoreDeleteQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.ScoreDelete]>(QueueName.ScoreDelete, {
connection: newRedis,
? new Queue<TQueueJobTypes[QueueName.ScoreDelete]>(
QueueName.ScoreDelete,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.ScoreDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -0,0 +1,20 @@
import { createHash } from "crypto";
/**
* Utility function to compute a consistent hash for a given key and map it to a shard index
* @param key - The key to hash
* @param shardCount - The number of shards to distribute across
* @returns A shard index between 0 and shardCount-1
*/
export function getShardIndex(key: string, shardCount: number): number {
if (shardCount <= 1) return 0;
// Create a consistent hash using SHA-256
const hash = createHash("sha256").update(key).digest("hex");
// Convert first 8 characters of hex to integer
const hashInt = parseInt(hash.substring(0, 8), 16);
// Map to shard index
return hashInt % shardCount;
}
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class TraceDeleteQueue {
@@ -22,6 +22,7 @@ export class TraceDeleteQueue {
QueueName.TraceDelete,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.TraceDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -1,6 +1,6 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import { logger } from "../logger";
export class TraceUpsertQueue {
@@ -22,6 +22,7 @@ export class TraceUpsertQueue {
QueueName.TraceUpsert,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.TraceUpsert),
defaultJobOptions: {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 100_000,
@@ -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({
@@ -1,5 +1,5 @@
import { Prompt, PrismaClient } from "@prisma/client";
import { Redis } from "ioredis";
import { Redis, Cluster } from "ioredis";
import { env } from "../../../env";
import { logger } from "../../logger";
import { escapeRegex } from "./utils";
@@ -22,7 +22,7 @@ export class PromptService {
constructor(
private prisma: PrismaClient,
private redis: Redis | null,
private redis: Redis | Cluster | null,
private metricIncrementer?: // used for otel metrics
(name: string, value?: number) => void,
cacheEnabled?: boolean, // used for testing
+1253 -981
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.65.3",
"version": "3.67.1-2",
"private": true,
"license": "MIT",
"engines": {
@@ -36,9 +36,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",
"@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,7 +120,7 @@
"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",
+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": []
@@ -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);
});
});
});
@@ -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
});
});
});
});
@@ -859,6 +859,149 @@ describe("OTel Resource Span Mapping", () => {
);
expect(parsedEvents).toHaveLength(2);
});
it("LFE-5171: should convert a Semantic Kernel 1.55+ OTel Span with new event-based semantic conventions to Langfuse Events", () => {
// Setup - Semantic Kernel 1.55+ uses new event names instead of deprecated gen_ai.content.prompt/completion
const resourceSpan = {
scopeSpans: [
{
scope: { name: "Microsoft.SemanticKernel" },
spans: [
{
traceId: {
type: "Buffer",
data: [
234, 103, 55, 8, 68, 28, 41, 132, 165, 74, 62, 57, 98, 211,
89, 95,
],
},
spanId: {
type: "Buffer",
data: [185, 4, 191, 251, 32, 190, 109, 126],
},
name: "chat_completion",
kind: 3,
startTimeUnixNano: {
low: 153687506,
high: 404677085,
unsigned: true,
},
endTimeUnixNano: {
low: 1327836088,
high: 404677085,
unsigned: true,
},
attributes: [
{
key: "gen_ai.response.model",
value: { stringValue: "gpt-4-0613" },
},
],
events: [
{
timeUnixNano: {
low: 1327691067,
high: 404677085,
unsigned: true,
},
name: "gen_ai.system.message",
attributes: [
{
key: "content",
value: {
stringValue: "You are a helpful assistant.",
},
},
],
},
{
timeUnixNano: {
low: 1327691068,
high: 404677085,
unsigned: true,
},
name: "gen_ai.user.message",
attributes: [
{
key: "content",
value: {
stringValue: "What is the capital of France?",
},
},
],
},
{
timeUnixNano: {
low: 1327737136,
high: 404677085,
unsigned: true,
},
name: "gen_ai.choice",
attributes: [
{
key: "index",
value: {
intValue: { low: 0, high: 0, unsigned: false },
},
},
{
key: "finish_reason",
value: { stringValue: "stop" },
},
{
key: "message",
value: {
stringValue: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
},
],
},
],
status: { code: 1 },
},
],
},
],
};
// When
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
// Then
// Will throw an error if the parsing fails
const parsedEvents = langfuseEvents.map((event) =>
ingestionEvent.parse(event),
);
expect(parsedEvents).toHaveLength(2);
// Check that input contains both system and user messages
const observationEvent = parsedEvents.find(
(event) => event.type === "generation-create",
);
expect(observationEvent?.body.input).toEqual([
{
role: "system",
content: "You are a helpful assistant.",
},
{
role: "user",
content: "What is the capital of France?",
},
]);
// Check that output contains the choice
expect(observationEvent?.body.output).toEqual({
index: 0,
finish_reason: "stop",
message: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
});
});
});
describe("Property Mapping", () => {
@@ -1701,6 +1844,180 @@ describe("OTel Resource Span Mapping", () => {
},
},
],
// Semantic Kernel 1.55+ new event-based semantic conventions
[
"should extract input from gen_ai.system.message event",
{
entity: "observation",
otelEventName: "gen_ai.system.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "You are a helpful assistant.",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "system",
content: "You are a helpful assistant.",
},
],
},
],
[
"should extract input from gen_ai.user.message event",
{
entity: "observation",
otelEventName: "gen_ai.user.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "What is the capital of France?",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "user",
content: "What is the capital of France?",
},
],
},
],
[
"should extract input from gen_ai.assistant.message event with tool_calls",
{
entity: "observation",
otelEventName: "gen_ai.assistant.message",
otelEventAttributeKey: "tool_calls",
otelEventAttributeValue: {
stringValue: JSON.stringify([{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location": "Paris"}'
}
}]),
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "assistant",
tool_calls: JSON.stringify([{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location": "Paris"}'
}
}]),
},
],
},
],
[
"should extract input from gen_ai.tool.message event",
{
entity: "observation",
otelEventName: "gen_ai.tool.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "Sunny, 22°C",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "tool",
content: "Sunny, 22°C",
},
],
},
],
[
"should extract output from gen_ai.choice event",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "message",
otelEventAttributeValue: {
stringValue: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
entityAttributeKey: "output",
entityAttributeValue: {
message: JSON.stringify({
role: "assistant",
content: "The capital of France is Paris.",
}),
},
},
],
[
"should extract output from gen_ai.choice event with finish_reason",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "finish_reason",
otelEventAttributeValue: {
stringValue: "stop",
},
entityAttributeKey: "output",
entityAttributeValue: {
finish_reason: "stop",
},
},
],
[
"should extract input from gen_ai.tool.message event with id attribute",
{
entity: "observation",
otelEventName: "gen_ai.tool.message",
otelEventAttributeKey: "id",
otelEventAttributeValue: {
stringValue: "call_456",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "tool",
id: "call_456",
},
],
},
],
[
"should extract output from gen_ai.choice event with index attribute",
{
entity: "observation",
otelEventName: "gen_ai.choice",
otelEventAttributeKey: "index",
otelEventAttributeValue: {
intValue: { low: 0, high: 0, unsigned: false },
},
entityAttributeKey: "output",
entityAttributeValue: {
index: 0,
},
},
],
[
"should extract input from gen_ai.assistant.message event with content",
{
entity: "observation",
otelEventName: "gen_ai.assistant.message",
otelEventAttributeKey: "content",
otelEventAttributeValue: {
stringValue: "I'll help you with that.",
},
entityAttributeKey: "input",
entityAttributeValue: [
{
role: "assistant",
content: "I'll help you with that.",
},
],
},
],
])(
"Events: %s",
(
+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
+2 -1
View File
@@ -8,6 +8,7 @@ import {
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { api } from "@/src/utils/api";
import { copyTextToClipboard } from "@/src/utils/clipboard";
import { CheckIcon, Globe, Link, Share2 } from "lucide-react";
import { useState } from "react";
@@ -96,7 +97,7 @@ const Base = (props: {
const copyUrl = () => {
setIsCopied(true);
void navigator.clipboard.writeText(window.location.href);
void copyTextToClipboard(window.location.href);
setTimeout(() => setIsCopied(false), 2500);
};
+145 -142
View File
@@ -1,6 +1,6 @@
"use client";
import { type OrderByState } from "@langfuse/shared";
import React, { useState, useMemo, useCallback, useContext } from "react";
import React, { useState, useMemo, useCallback } from "react";
import DocPopup from "@/src/components/layouts/doc-popup";
import { DataTablePagination } from "@/src/components/table/data-table-pagination";
import {
@@ -37,6 +37,7 @@ import { TablePeekView } from "@/src/components/table/peek";
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
import { usePeekView } from "@/src/components/table/peek/hooks/usePeekView";
import { isEqual } from "lodash";
import { useRouter } from "next/router";
interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -98,8 +99,6 @@ function isValidCssVariableName({
return regex.test(name);
}
const SelectedRowContext = React.createContext<string | undefined>(undefined);
export function DataTable<TData extends object, TValue>({
columns,
data,
@@ -186,22 +185,24 @@ export function DataTable<TData extends object, TValue>({
[],
);
const { inflatedPeekView, peekViewId, handleOnRowClickPeek } = usePeekView({
const {
row: peekRow,
handleOnRowClickPeek,
peekViewId,
} = usePeekView({
getRow: getRowMemoized,
peekView,
});
const handleOnRowClick = useCallback(
(row: TData) => {
if (inflatedPeekView) {
handleOnRowClickPeek?.(row);
}
handleOnRowClickPeek?.(row);
onRowClick?.(row);
},
[handleOnRowClickPeek, onRowClick, inflatedPeekView],
[handleOnRowClickPeek, onRowClick],
);
const hasRowClickAction = !!onRowClick || !!inflatedPeekView;
const hasRowClickAction = !!onRowClick || !!peekView;
// memo column sizes for performance
// https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
@@ -241,151 +242,151 @@ export function DataTable<TData extends object, TValue>({
className={cn("relative w-full overflow-auto border-t")}
style={{ ...columnSizeVars }}
>
<SelectedRowContext.Provider value={peekViewId}>
<Table>
<TableHeader className="sticky top-0 z-10">
{tableHeaders.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnDef = header.column
.columnDef as LangfuseColumnDef<ModelTableRow>;
const sortingEnabled = columnDef.enableSorting;
// if the header id does not translate to a valid css variable name, default to 150px as width
// may only happen for dynamic columns, as column names are user defined
const width = isValidCssVariableName({
name: header.id,
includesHyphens: false,
})
? `calc(var(--header-${header.id}-size) * 1px)`
: 150;
<Table>
<TableHeader className="sticky top-0 z-10">
{tableHeaders.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnDef = header.column
.columnDef as LangfuseColumnDef<ModelTableRow>;
const sortingEnabled = columnDef.enableSorting;
// if the header id does not translate to a valid css variable name, default to 150px as width
// may only happen for dynamic columns, as column names are user defined
const width = isValidCssVariableName({
name: header.id,
includesHyphens: false,
})
? `calc(var(--header-${header.id}-size) * 1px)`
: 150;
return header.column.getIsVisible() ? (
<TableHead
key={header.id}
className={cn(
"group p-1 first:pl-2",
sortingEnabled && "cursor-pointer",
pinFirstColumn &&
header.index === 0 &&
"sticky left-0 z-20 border-r bg-background",
)}
style={{ width }}
onClick={(event) => {
event.preventDefault();
return header.column.getIsVisible() ? (
<TableHead
key={header.id}
className={cn(
"group p-1 first:pl-2",
sortingEnabled && "cursor-pointer",
pinFirstColumn &&
header.index === 0 &&
"sticky left-0 z-20 border-r bg-background",
)}
style={{ width }}
onClick={(event) => {
event.preventDefault();
if (
!setOrderBy ||
!columnDef.id ||
!sortingEnabled
) {
return;
}
if (!setOrderBy || !columnDef.id || !sortingEnabled) {
return;
}
if (orderBy?.column === columnDef.id) {
if (orderBy.order === "DESC") {
capture("table:column_sorting_header_click", {
column: columnDef.id,
order: "ASC",
});
setOrderBy({
column: columnDef.id,
order: "ASC",
});
} else {
capture("table:column_sorting_header_click", {
column: columnDef.id,
order: "Disabled",
});
setOrderBy(null);
}
} else {
if (orderBy?.column === columnDef.id) {
if (orderBy.order === "DESC") {
capture("table:column_sorting_header_click", {
column: columnDef.id,
order: "DESC",
order: "ASC",
});
setOrderBy({
column: columnDef.id,
order: "DESC",
order: "ASC",
});
} else {
capture("table:column_sorting_header_click", {
column: columnDef.id,
order: "Disabled",
});
setOrderBy(null);
}
}}
>
{header.isPlaceholder ? null : (
<div className="flex select-none items-center">
<span className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{columnDef.headerTooltip && (
<DocPopup
description={
columnDef.headerTooltip.description
}
href={columnDef.headerTooltip.href}
/>
} else {
capture("table:column_sorting_header_click", {
column: columnDef.id,
order: "DESC",
});
setOrderBy({
column: columnDef.id,
order: "DESC",
});
}
}}
>
{header.isPlaceholder ? null : (
<div className="flex select-none items-center">
<span className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{orderBy?.column === columnDef.id
? renderOrderingIndicator(orderBy)
: null}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
header.column.getIsResizing() &&
"bg-primary-accent opacity-100",
)}
</span>
{columnDef.headerTooltip && (
<DocPopup
description={
columnDef.headerTooltip.description
}
href={columnDef.headerTooltip.href}
/>
</div>
)}
</TableHead>
) : null;
})}
</TableRow>
))}
</TableHeader>
{table.getState().columnSizingInfo.isResizingColumn ||
!!peekView ? (
<MemoizedTableBody
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
pinFirstColumn={pinFirstColumn}
tableSnapshot={{
tableDataUpdatedAt: peekView?.tableDataUpdatedAt,
columnVisibility,
columnOrder,
rowSelection,
}}
/>
) : (
<TableBodyComponent
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
pinFirstColumn={pinFirstColumn}
/>
)}
</Table>
</SelectedRowContext.Provider>
)}
{orderBy?.column === columnDef.id
? renderOrderingIndicator(orderBy)
: null}
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
header.column.getIsResizing() &&
"bg-primary-accent opacity-100",
)}
/>
</div>
)}
</TableHead>
) : null;
})}
</TableRow>
))}
</TableHeader>
{table.getState().columnSizingInfo.isResizingColumn ||
!!peekView ? (
<MemoizedTableBody
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
pinFirstColumn={pinFirstColumn}
tableSnapshot={{
tableDataUpdatedAt: peekView?.tableDataUpdatedAt,
columnVisibility,
columnOrder,
rowSelection,
}}
/>
) : (
<TableBodyComponent
table={table}
rowheighttw={rowheighttw}
columns={columns}
data={data}
help={help}
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
pinFirstColumn={pinFirstColumn}
/>
)}
</Table>
</div>
<div className="grow"></div>
</div>
{inflatedPeekView && <TablePeekView {...inflatedPeekView} />}
{peekView && (
<TablePeekView
peekView={peekView}
row={peekRow}
selectedRowId={peekViewId}
/>
)}
{!hidePagination && pagination !== undefined ? (
<div
className={cn(
@@ -439,7 +440,8 @@ function TableRowComponent<TData>({
onRowClick?: (row: TData) => void;
children: React.ReactNode;
}) {
const peekViewId = useContext(SelectedRowContext);
const router = useRouter();
const selectedRowId = router.query.peek as string | undefined;
return (
<TableRow
data-row-index={row.index}
@@ -452,7 +454,7 @@ function TableRowComponent<TData>({
className={cn(
"hover:bg-accent",
!!onRowClick ? "cursor-pointer" : "cursor-default",
peekViewId && peekViewId === row.id ? "bg-accent" : undefined,
selectedRowId && selectedRowId === row.id ? "bg-accent" : undefined,
)}
>
{children}
@@ -544,8 +546,9 @@ const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
if (
prev.tableSnapshot.tableDataUpdatedAt !==
next.tableSnapshot.tableDataUpdatedAt
)
) {
return false;
}
if (prev.table.options.data !== next.table.options.data) return false;
if (prev.data.isLoading !== next.data.isLoading) return false;
if (prev.rowheighttw !== next.rowheighttw) return false;
+40 -36
View File
@@ -11,6 +11,8 @@ import { ItemBadge, type LangfuseItemType } from "@/src/components/ItemBadge";
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
import { cn } from "@/src/utils/tailwind";
import { memo } from "react";
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
type PeekViewItemType = Extract<
LangfuseItemType,
@@ -38,12 +40,6 @@ export type DataTablePeekViewProps<TData> = {
/** Custom prefix for the peek view title */
customTitlePrefix?: string;
// Data
/** The currently selected row ID */
selectedRowId?: string | null;
/** The row data for the selected item */
row?: TData;
// Navigation and URL handling
/** The base pathname for constructing URLs */
urlPathname: string;
@@ -87,19 +83,15 @@ export const createPeekEventHandler = (options?: PeekEventControlOptions) => {
};
};
export function TablePeekView<TData>({
itemType,
selectedRowId,
onOpenChange,
onExpand,
getNavigationPath,
children,
listKey,
peekEventOptions,
row,
customTitlePrefix,
}: DataTablePeekViewProps<TData>) {
const eventHandler = createPeekEventHandler(peekEventOptions);
type TablePeekViewProps<T> = {
peekView: PeekViewProps<T>;
row?: T;
selectedRowId?: string | null;
};
function TablePeekViewComponent<TData>(props: TablePeekViewProps<TData>) {
const { peekView, row, selectedRowId } = props;
const eventHandler = createPeekEventHandler(peekView.peekEventOptions);
if (!selectedRowId) return null;
@@ -108,13 +100,17 @@ export function TablePeekView<TData>({
return;
}
if (!!row && typeof row === "object" && "timestamp" in row) {
onOpenChange(open, selectedRowId, (row as any).timestamp.toISOString());
peekView.onOpenChange(
open,
selectedRowId,
(row as any).timestamp.toISOString(),
);
} else {
onOpenChange(open, selectedRowId);
peekView.onOpenChange(open, selectedRowId);
}
};
const canExpand = typeof onExpand === "function";
const canExpand = typeof peekView.onExpand === "function";
return (
<Sheet open={!!selectedRowId} onOpenChange={handleOpenChange} modal={false}>
@@ -128,13 +124,13 @@ export function TablePeekView<TData>({
>
<SheetHeader className="flex min-h-12 flex-row flex-nowrap items-center justify-between rounded-t-xl bg-header px-2">
<SheetTitle className="!mt-0 ml-2 flex min-w-0 flex-row items-center gap-2">
<ItemBadge type={itemType} showLabel />
<ItemBadge type={peekView.itemType} showLabel />
<span
className="truncate text-sm font-medium focus:outline-none"
tabIndex={0}
>
{customTitlePrefix
? `${customTitlePrefix} ${selectedRowId}`
{peekView.customTitlePrefix
? `${peekView.customTitlePrefix} ${selectedRowId}`
: selectedRowId}
</span>
</SheetTitle>
@@ -144,13 +140,15 @@ export function TablePeekView<TData>({
!canExpand && "mr-8",
)}
>
{selectedRowId && listKey && getNavigationPath && (
<DetailPageNav
currentId={selectedRowId}
path={getNavigationPath}
listKey={listKey}
/>
)}
{selectedRowId &&
peekView.listKey &&
peekView.getNavigationPath && (
<DetailPageNav
currentId={selectedRowId}
path={peekView.getNavigationPath}
listKey={peekView.listKey}
/>
)}
{canExpand && (
<div className="!mt-0 mr-8 flex h-full flex-row items-center gap-1 border-l">
<Button
@@ -158,7 +156,7 @@ export function TablePeekView<TData>({
size="icon-xs"
title="Open in current tab"
className="ml-2"
onClick={() => onExpand?.(false, row)}
onClick={() => peekView.onExpand?.(false, row)}
>
<Expand className="h-4 w-4" />
</Button>
@@ -166,7 +164,7 @@ export function TablePeekView<TData>({
variant="ghost"
size="icon-xs"
title="Open in new tab"
onClick={() => onExpand?.(true, row)}
onClick={() => peekView.onExpand?.(true, row)}
>
<ExternalLink className="h-4 w-4" />
</Button>
@@ -176,11 +174,17 @@ export function TablePeekView<TData>({
</SheetHeader>
<Separator />
<div className="flex max-h-full min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-auto">
{typeof children === "function" ? children(row) : children}
<div className="flex-1 overflow-auto" key={selectedRowId}>
{typeof peekView.children === "function"
? peekView.children(row)
: peekView.children}
</div>
</div>
</SheetContent>
</Sheet>
);
}
export const TablePeekView = memo(TablePeekViewComponent, (prev, next) => {
return prev.selectedRowId === next.selectedRowId;
}) as typeof TablePeekViewComponent;
@@ -23,6 +23,7 @@ export const usePeekData = ({
if (error.data?.code === "UNAUTHORIZED") return false;
return failureCount < 3;
},
staleTime: 60 * 1000, // 1 minute
},
);
};
@@ -17,10 +17,7 @@ import { useEffect, useRef, useState } from "react";
* isTableDataComplete: !metricsQuery.isLoading && metricsQuery.data !== undefined
* ```
*/
export type PeekViewProps<TData> = Omit<
DataTablePeekViewProps<TData>,
"selectedRowId" | "row"
> & {
export type PeekViewProps<TData> = DataTablePeekViewProps<TData> & {
tableDataUpdatedAt: number;
};
@@ -50,8 +47,8 @@ type UsePeekViewProps<TData> = {
*
* @returns An object containing:
* - handleOnRowClickPeek: Function to handle row clicks for peek view
* - inflatedPeekView: The peek view props with the selected row data
* - peekViewId: The ID of the currently selected row for peek view
* - row: The currently selected row for peek view
*
* The peek view allows users to preview details of a row without navigating away from the table.
* It manages the URL state via query parameters and handles row selection/deselection.
@@ -88,25 +85,21 @@ export const usePeekView = <TData extends object>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const inflatedPeekView = peekView
? { ...peekView, selectedRowId: peekViewId, row }
: undefined;
// Update the row state when the user clicks on a row
const handleOnRowClickPeek = (row: TData) => {
if (inflatedPeekView) {
if (peekView) {
const rowId =
"id" in row && typeof row.id === "string" ? row.id : undefined;
// If clicking the same row that's already open, close it
if (rowId === inflatedPeekView.selectedRowId) {
inflatedPeekView.onOpenChange(false);
if (rowId === peekViewId) {
peekView.onOpenChange(false);
setRow(undefined);
}
// If clicking a different row update the row data and URL
else {
const timestamp =
"timestamp" in row ? (row.timestamp as Date) : undefined;
inflatedPeekView.onOpenChange(true, rowId, timestamp?.toISOString());
peekView.onOpenChange(true, rowId, timestamp?.toISOString());
setRow(row);
}
}
@@ -120,7 +113,7 @@ export const usePeekView = <TData extends object>({
row && "id" in row && typeof row.id === "string" ? row.id : undefined;
if (peekViewId !== rowId) {
if (peekViewId && inflatedPeekView) {
if (peekViewId && peekView) {
try {
const row = getRow(peekViewId);
if (row) {
@@ -136,7 +129,7 @@ export const usePeekView = <TData extends object>({
return {
handleOnRowClickPeek: peekView ? handleOnRowClickPeek : undefined,
inflatedPeekView,
peekViewId,
row,
};
};
@@ -39,6 +39,7 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
DialogBody,
} from "@/src/components/ui/dialog";
import { Input } from "@/src/components/ui/input";
import {
@@ -571,34 +572,37 @@ export function TableViewPresetsDrawer({
onSubmit={form.handleSubmit(onSubmit())}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>View name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogBody>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>View name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 text-sm text-muted-foreground">
<p>This will save the current:</p>
<ul className="mt-2 list-disc pl-5">
<li>
Column arrangement ({currentState.columnOrder.length}{" "}
columns)
</li>
<li>Filters ({currentState.filters.length} active)</li>
<li>
Sort order ({formatOrderBy(currentState.orderBy)} criteria)
</li>
{currentState.searchQuery && <li>Search term</li>}
</ul>
</div>
<div className="mt-4 text-sm text-muted-foreground">
<p>This will save the current:</p>
<ul className="mt-2 list-disc pl-5">
<li>
Column arrangement ({currentState.columnOrder.length}{" "}
columns)
</li>
<li>Filters ({currentState.filters.length} active)</li>
<li>
Sort order ({formatOrderBy(currentState.orderBy)}{" "}
criteria)
</li>
{currentState.searchQuery && <li>Search term</li>}
</ul>
</div>
</DialogBody>
<DialogFooter>
<Button
@@ -1,5 +1,6 @@
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { api } from "@/src/utils/api";
import { copyTextToClipboard } from "@/src/utils/clipboard";
type UseViewMutationsProps = {
handleSetViewId: (viewId: string | null) => void;
@@ -42,7 +43,7 @@ export const useViewMutations = ({
const generatePermalinkMutation =
api.TableViewPresets.generatePermalink.useMutation({
onSuccess: (data) => {
navigator.clipboard.writeText(data);
copyTextToClipboard(data);
showSuccessToast({
title: "Permalink copied to clipboard",
description: "You can now share the permalink with others",
@@ -22,7 +22,7 @@ import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { observationsTableColsWithOptions } from "@langfuse/shared";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
@@ -54,6 +54,7 @@ import { useObservationPeekNavigation } from "@/src/components/table/peek/hooks/
import { useTableViewManager } from "@/src/components/table/table-view-presets/hooks/useTableViewManager";
import { useRouter } from "next/router";
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
export type ObservationsTableRow = {
// Shown by default
@@ -879,6 +880,31 @@ export default function ObservationsTable({
},
});
const peekConfig: PeekViewProps<ObservationsTableRow> = useMemo(
() => ({
itemType: "TRACE",
customTitlePrefix: "Observation ID:",
listKey: "observations",
urlPathname,
onOpenChange: setPeekView,
onExpand: expandPeek,
shouldUpdateRowOnDetailPageNavigation: true,
getNavigationPath,
children: (row?: ObservationsTableRow) => (
<PeekViewObservationDetail projectId={projectId} row={row} />
),
tableDataUpdatedAt: generations.dataUpdatedAt,
}),
[
projectId,
urlPathname,
generations.dataUpdatedAt,
getNavigationPath,
expandPeek,
setPeekView,
],
);
const rows: ObservationsTableRow[] = useMemo(() => {
return generations.isSuccess
? generations.data.generations.map((generation) => {
@@ -969,20 +995,7 @@ export default function ObservationsTable({
/>
<DataTable
columns={columns}
peekView={{
itemType: "RUNNING_EVALUATOR",
customTitlePrefix: "Observation ID:",
listKey: "observations",
urlPathname,
onOpenChange: setPeekView,
onExpand: expandPeek,
shouldUpdateRowOnDetailPageNavigation: true,
getNavigationPath,
children: (row) => (
<PeekViewObservationDetail projectId={projectId} row={row} />
),
tableDataUpdatedAt: generations.dataUpdatedAt,
}}
peekView={peekConfig}
data={
generations.isLoading || isViewLoading
? { isLoading: true, isError: false }
@@ -1039,24 +1052,22 @@ const GenerationsDynamicCell = ({
},
{
enabled: typeof traceId === "string" && typeof observationId === "string",
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
staleTime: 60 * 1000, // 1 minute
},
);
const data =
col === "output"
? observation.data?.output
: col === "input"
? observation.data?.input
: observation.data?.metadata;
return (
<IOTableCell
<MemoizedIOTableCell
isLoading={observation.isLoading}
data={
col === "output"
? observation.data?.output
: col === "input"
? observation.data?.input
: observation.data?.metadata
}
data={data}
className={cn(col === "output" && "bg-accent-light-green")}
singleLine={singleLine}
/>
+48 -39
View File
@@ -34,7 +34,7 @@ import {
TableViewPresetTableName,
} from "@langfuse/shared";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
@@ -249,6 +249,8 @@ export default function TracesTable({
const traces = api.traces.all.useQuery(tracesAllQueryFilter, {
enabled: environmentFilterOptions.data !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const traceMetrics = api.traces.metrics.useQuery(
@@ -259,6 +261,8 @@ export default function TracesTable({
},
{
enabled: traces.data !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
@@ -994,6 +998,36 @@ export default function TracesTable({
const { getNavigationPath, expandPeek } = useTracePeekNavigation(urlPathname);
const { setPeekView } = useTracePeekState(urlPathname);
const peekConfig = useMemo(() => {
if (hideControls) return undefined;
return {
itemType: "TRACE" as const,
listKey: "traces",
urlPathname,
peekEventOptions: {
ignoredSelectors: ['[role="checkbox"]', '[aria-label="bookmark"]'],
},
onOpenChange: setPeekView,
onExpand: expandPeek,
getNavigationPath,
children: <PeekViewTraceDetail projectId={projectId} />,
tableDataUpdatedAt: Math.max(
traces.dataUpdatedAt,
traceMetrics.dataUpdatedAt,
),
};
}, [
projectId,
hideControls,
setPeekView,
expandPeek,
getNavigationPath,
urlPathname,
traces.dataUpdatedAt,
traceMetrics.dataUpdatedAt,
]);
const { isLoading: isViewLoading, ...viewControllers } = useTableViewManager({
tableName: TableViewPresetTableName.Traces,
projectId,
@@ -1059,7 +1093,7 @@ export default function TracesTable({
};
}) ?? [])
: [];
}, [traces, traceRowData, scoreKeysAndProps]);
}, [traces.isSuccess, traceRowData?.rows, scoreKeysAndProps]);
const setFilterState = useDebounce(setUserFilterState);
@@ -1161,29 +1195,7 @@ export default function TracesTable({
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
pinFirstColumn={!hideControls}
peekView={
hideControls
? undefined
: {
itemType: "TRACE",
listKey: "traces",
urlPathname,
peekEventOptions: {
ignoredSelectors: [
'[role="checkbox"]',
'[aria-label="bookmark"]',
],
},
onOpenChange: setPeekView,
onExpand: expandPeek,
getNavigationPath,
children: <PeekViewTraceDetail projectId={projectId} />,
tableDataUpdatedAt: Math.max(
traces.dataUpdatedAt,
traceMetrics.dataUpdatedAt,
),
}
}
peekView={peekConfig}
/>
</>
);
@@ -1205,25 +1217,22 @@ const TracesDynamicCell = ({
const trace = api.traces.byId.useQuery(
{ traceId, projectId, timestamp },
{
enabled: typeof traceId === "string",
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
staleTime: 60 * 1000, // 1 minute
},
);
const data =
col === "output"
? trace.data?.output
: col === "input"
? trace.data?.input
: trace.data?.metadata;
return (
<IOTableCell
<MemoizedIOTableCell
isLoading={trace.isLoading}
data={
col === "output"
? trace.data?.output
: col === "input"
? trace.data?.input
: trace.data?.metadata
}
data={data}
className={cn(col === "output" && "bg-accent-light-green")}
singleLine={singleLine}
/>
+2 -1
View File
@@ -8,6 +8,7 @@ import {
} from "@/src/components/ui/popover";
import { cn } from "@/src/utils/tailwind";
import { Label } from "@/src/components/ui/label";
import { copyTextToClipboard } from "@/src/utils/clipboard";
interface IdItem {
name: string;
@@ -24,7 +25,7 @@ export const CopyIdsPopover = ({
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopy = (textToCopy: string) => {
navigator.clipboard.writeText(textToCopy);
copyTextToClipboard(textToCopy);
setCopiedId(textToCopy);
setTimeout(() => setCopiedId(null), 1500);
};
+9 -5
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useMemo, useState } from "react";
import { Button } from "@/src/components/ui/button";
import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
@@ -13,6 +13,7 @@ import { type MediaReturnType } from "@/src/features/media/validation";
import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
import { renderContentWithPromptButtons } from "@/src/features/prompts/components/renderContentWithPromptButtons";
import { copyTextToClipboard } from "@/src/utils/clipboard";
const IO_TABLE_CHAR_LIMIT = 10000;
@@ -41,7 +42,8 @@ export function JSONView(props: {
: (props.collapseStringsAfterLength ?? 500);
const handleOnCopy = () => {
void navigator.clipboard.writeText(stringifyJsonNode(parsedJson));
const textToCopy = stringifyJsonNode(parsedJson);
void copyTextToClipboard(textToCopy);
};
const handleOnValueChange = () => {
@@ -158,11 +160,11 @@ export function CodeView(props: {
const handleCopy = () => {
setIsCopied(true);
void navigator.clipboard.writeText(
const content =
typeof props.content === "string"
? props.content
: (props.content?.join("\n") ?? ""),
);
: (props.content?.join("\n") ?? "");
void copyTextToClipboard(content);
setTimeout(() => setIsCopied(false), 1000);
};
@@ -302,6 +304,8 @@ export const IOTableCell = ({
);
};
export const MemoizedIOTableCell = memo(IOTableCell);
export const JsonSkeleton = ({
className,
numRows = 10,
+2 -1
View File
@@ -1,4 +1,5 @@
import { Button } from "@/src/components/ui/button";
import { copyTextToClipboard } from "@/src/utils/clipboard";
import { cn } from "@/src/utils/tailwind";
import { Check, Copy } from "lucide-react";
import { type FC, memo, useState } from "react";
@@ -50,7 +51,7 @@ const CodeBlock: FC<Props> = memo(({ language, value, theme, className }) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = () => {
setIsCopied(true);
void navigator.clipboard.writeText(value ?? "");
void copyTextToClipboard(value ?? "");
setTimeout(() => setIsCopied(false), 1000);
};
+44 -3
View File
@@ -33,6 +33,8 @@ import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
import { type MediaReturnType } from "@/src/features/media/validation";
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
import { copyTextToClipboard } from "@/src/utils/clipboard";
import DOMPurify from "dompurify";
type ReactMarkdownNode = ReactMarkdownExtraProps["node"];
type ReactMarkdownNodeChildren = Exclude<
@@ -49,6 +51,33 @@ const MemoizedReactMarkdown: FC<Options> = memo(
prevProps.className === nextProps.className,
);
const getSafeUrl = (href: string | undefined | null): string | null => {
if (!href || typeof href !== "string") return null;
// DOMPurify's default sanitization is quite permissive but safe
// It blocks javascript:, data: with scripts, vbscript:, etc.
// But allows http:, https:, ftp:, mailto:, tel:, and many others
try {
const sanitized = DOMPurify.sanitize(href, {
// ALLOWED_TAGS: An array of HTML tags that are explicitly permitted in the output.
// Setting this to an empty array means that no HTML tags are allowed.
// Any HTML tag found within the 'href' string would be stripped out.
ALLOWED_TAGS: [],
// ALLOWED_ATTR: An array of HTML attributes that are explicitly permitted on allowed tags.
// Setting this to an empty array means that no HTML attributes are allowed.
// Similar to ALLOWED_TAGS, this ensures that if any attributes are somehow
// embedded within the URL string (e.g., malformed or attempting injection),
// they will be removed by DOMPurify. We only expect a pure URL string.
ALLOWED_ATTR: [],
});
return sanitized || null;
} catch (error) {
return null;
}
};
const isTextElement = (child: ReactNode): child is ReactElement =>
isValidElement(child) &&
typeof child.type !== "string" &&
@@ -109,12 +138,24 @@ function MarkdownRenderer({
);
},
a({ children, href }) {
if (href)
const safeHref = getSafeUrl(href);
if (safeHref) {
return (
<Link href={href} className="underline" target="_blank">
<Link
href={safeHref}
className="underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</Link>
);
}
return (
<span className="text-muted-foreground underline">
{children}
</span>
);
},
ul({ children }) {
if (isChecklist(children))
@@ -271,7 +312,7 @@ export function MarkdownView({
typeof markdown === "string"
? markdown
: parseOpenAIContentParts(markdown);
void navigator.clipboard.writeText(rawText);
void copyTextToClipboard(rawText);
};
const handleOnValueChange = () => {
+18 -8
View File
@@ -1,12 +1,17 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { DialogTitle, type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/src/utils/tailwind";
import { Dialog, DialogContent } from "@/src/components/ui/dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
} from "@/src/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -36,12 +41,17 @@ const CommandDialog = ({
className="overflow-hidden p-0 shadow-lg"
closeOnInteractionOutside
>
<Command
filter={filter}
className="pb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12"
>
{children}
</Command>
<DialogHeader className="sr-only p-0">
<DialogTitle>Search</DialogTitle>
</DialogHeader>
<DialogBody className="p-0">
<Command
filter={filter}
className="pb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12"
>
{children}
</Command>
</DialogBody>
</DialogContent>
</Dialog>
);
+39 -9
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/src/utils/tailwind";
@@ -29,24 +30,37 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const dialogContentVariants = cva(
"fixed left-[50%] top-[50%] z-50 flex w-full flex-col translate-x-[-50%] translate-y-[-50%] bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
{
variants: {
size: {
default: "max-w-lg max-h-[85vh]",
lg: "max-w-4xl max-h-[85vh]",
xl: "max-w-7xl h-[90vh]",
},
},
defaultVariants: {
size: "default",
},
},
);
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
closeOnInteractionOutside?: boolean;
}
} & VariantProps<typeof dialogContentVariants>
>(
(
{ className, children, closeOnInteractionOutside = false, ...props },
{ className, children, closeOnInteractionOutside = false, size, ...props },
ref,
) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid max-h-[85vh] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
className={cn(dialogContentVariants({ size, className }))}
aria-describedby={undefined}
onPointerDownOutside={(e) => {
if (!closeOnInteractionOutside) {
@@ -61,7 +75,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 z-20 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -77,7 +91,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"sticky top-0 z-10 flex flex-shrink-0 flex-col space-y-1.5 border-b bg-background p-6 pb-4 text-center sm:text-left",
className,
)}
{...props}
@@ -85,13 +99,28 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
const DialogBody = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex flex-1 flex-col gap-4 overflow-y-auto px-6 pb-6 [&:has(+.dialog-footer)]:pb-0",
className,
)}
{...props}
/>
));
DialogBody.displayName = "DialogBody";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"dialog-footer sticky bottom-0 z-10 flex flex-shrink-0 flex-col-reverse border-t bg-background p-6 px-6 sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
@@ -134,6 +163,7 @@ export {
DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogDescription,
+6 -4
View File
@@ -8,7 +8,7 @@ import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { Dialog, DialogContent } from "@/src/components/ui/dialog";
import { Dialog, DialogBody, DialogContent } from "@/src/components/ui/dialog";
import { cn } from "@/src/utils/tailwind";
const InputCommand = React.forwardRef<
@@ -35,9 +35,11 @@ const InputCommandDialog = ({
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<InputCommand className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 dark:[&_[cmdk-group-heading]]:text-slate-400 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</InputCommand>
<DialogBody>
<InputCommand className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 dark:[&_[cmdk-group-heading]]:text-slate-400 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</InputCommand>
</DialogBody>
</DialogContent>
</Dialog>
);
+1 -1
View File
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-72 max-w-[max(var(--radix-popover-trigger-width),72px)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.65.3";
export const VERSION = "v3.67.1-2";
@@ -23,6 +23,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogBody,
} from "@/src/components/ui/dialog";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { stripeProducts } from "@/src/ee/features/billing/utils/stripeProducts";
@@ -230,122 +231,126 @@ const BillingPortalOrPricingPageButton = () => {
}
/>
</DialogHeader>
<div className="mb-3 grid grid-cols-1 gap-3 md:grid-cols-3">
{stripeProducts
.filter((product) => Boolean(product.checkout))
.map((product) => (
<div
key={product.stripeProductId}
className="relative flex flex-col rounded-xl border bg-card p-4 shadow-sm transition-all hover:shadow-md"
>
<div className="mb-4">
<h3 className="text-2xl font-bold">
{product.checkout?.title}
</h3>
<div className="mt-4 space-y-1">
<div className="text-2xl font-bold text-primary">
{product.checkout?.price}
</div>
<div className="text-sm text-muted-foreground">
+ {product.checkout?.usagePrice}
<DialogBody>
<div className="mb-3 grid grid-cols-1 gap-3 md:grid-cols-3">
{stripeProducts
.filter((product) => Boolean(product.checkout))
.map((product) => (
<div
key={product.stripeProductId}
className="relative flex flex-col rounded-xl border bg-card p-4 shadow-sm transition-all hover:shadow-md"
>
<div className="mb-4">
<h3 className="text-2xl font-bold">
{product.checkout?.title}
</h3>
<div className="mt-4 space-y-1">
<div className="text-2xl font-bold text-primary">
{product.checkout?.price}
</div>
<div className="text-sm text-muted-foreground">
+ {product.checkout?.usagePrice}
</div>
</div>
</div>
</div>
<div className="mb-4 text-sm text-muted-foreground">
{product.checkout?.description}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Main features:</div>
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
{product.checkout?.mainFeatures.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
</div>
<Link
href="https://langfuse.com/pricing"
target="_blank"
className="mt-auto block py-4 text-sm text-muted-foreground hover:text-foreground"
>
Learn more about plan
</Link>
{organization?.cloudConfig?.stripe?.activeProductId ? (
// Change plan
<Dialog>
<DialogTrigger asChild>
<Button
disabled={
organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
}
className="w-full"
>
{organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
? "Current plan"
: "Change plan"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Confirm Change:{" "}
{planLabels[organization?.plan ?? "cloud:hobby"]} {" "}
{product.checkout?.title}
</DialogTitle>
<DialogDescription className="pt-2">
This will immediately generate an invoice for any
usage on your current plan. Your new plan and billing
period will start today. Are you sure you want to
continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<ActionButton
onClick={() => {
if (organization) {
setProcessingPlanId(product.stripeProductId);
mutChangePlan.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}
}}
loading={processingPlanId === product.stripeProductId}
>
Confirm
</ActionButton>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
// Upgrade, no plan yet
<ActionButton
onClick={() => {
if (organization) {
setProcessingPlanId(product.stripeProductId);
mutCreateCheckoutSession.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}
}}
disabled={
organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
}
className="w-full"
loading={processingPlanId === product.stripeProductId}
<div className="mb-4 text-sm text-muted-foreground">
{product.checkout?.description}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Main features:</div>
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
{product.checkout?.mainFeatures.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
</div>
<Link
href="https://langfuse.com/pricing"
target="_blank"
className="mt-auto block py-4 text-sm text-muted-foreground hover:text-foreground"
>
Select plan
</ActionButton>
)}
</div>
))}
</div>
Learn more about plan
</Link>
{organization?.cloudConfig?.stripe?.activeProductId ? (
// Change plan
<Dialog>
<DialogTrigger asChild>
<Button
disabled={
organization?.cloudConfig?.stripe
?.activeProductId === product.stripeProductId
}
className="w-full"
>
{organization?.cloudConfig?.stripe
?.activeProductId === product.stripeProductId
? "Current plan"
: "Change plan"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Confirm Change:{" "}
{planLabels[organization?.plan ?? "cloud:hobby"]} {" "}
{product.checkout?.title}
</DialogTitle>
<DialogDescription className="pt-2">
This will immediately generate an invoice for any
usage on your current plan. Your new plan and
billing period will start today. Are you sure you
want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<ActionButton
onClick={() => {
if (organization) {
setProcessingPlanId(product.stripeProductId);
mutChangePlan.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}
}}
loading={
processingPlanId === product.stripeProductId
}
>
Confirm
</ActionButton>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
// Upgrade, no plan yet
<ActionButton
onClick={() => {
if (organization) {
setProcessingPlanId(product.stripeProductId);
mutCreateCheckoutSession.mutate({
orgId: organization.id,
stripeProductId: product.stripeProductId,
});
}
}}
disabled={
organization?.cloudConfig?.stripe?.activeProductId ===
product.stripeProductId
}
className="w-full"
loading={processingPlanId === product.stripeProductId}
>
Select plan
</ActionButton>
)}
</div>
))}
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
-19
View File
@@ -198,20 +198,6 @@ export const env = createEnv({
)
.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",
})
.positive()
.max(65536, `options.port should be >= 0 and < 65536`)
.default(6379)
.nullable(),
REDIS_AUTH: z.string().nullish(),
REDIS_CONNECTION_STRING: z.string().nullish(),
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
// langfuse caching
LANGFUSE_CACHE_API_KEY_ENABLED: z.enum(["true", "false"]).default("false"),
LANGFUSE_CACHE_API_KEY_TTL_SECONDS: z.coerce.number().default(120),
@@ -511,11 +497,6 @@ export const env = createEnv({
LANGFUSE_EE_LICENSE_KEY: process.env.LANGFUSE_EE_LICENSE_KEY,
ADMIN_API_KEY: process.env.ADMIN_API_KEY,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
REDIS_HOST: process.env.REDIS_HOST,
REDIS_PORT: process.env.REDIS_PORT,
REDIS_AUTH: process.env.REDIS_AUTH,
REDIS_CONNECTION_STRING: process.env.REDIS_CONNECTION_STRING,
REDIS_ENABLE_AUTO_PIPELINING: process.env.REDIS_ENABLE_AUTO_PIPELINING,
// langfuse caching
LANGFUSE_CACHE_API_KEY_ENABLED: process.env.LANGFUSE_CACHE_API_KEY_ENABLED,
LANGFUSE_CACHE_API_KEY_TTL_SECONDS:
@@ -2,8 +2,10 @@ import { Button, type ButtonProps } from "@/src/components/ui/button";
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -204,103 +206,109 @@ export const CreateOrEditAnnotationQueueButton = ({
</DialogHeader>
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
type="text"
className="text-xs"
onBlur={(e) => field.onChange(e.target.value.trimEnd())}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Add description..."
className="text-xs focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreConfigIds"
render={({ field }) => (
<FormItem>
<FormLabel>Score Configs</FormLabel>
<FormDescription>
Define which dimensions annotators should score for the
given queue.
</FormDescription>
<FormControl>
<MultiSelectKeyValues
placeholder="Value"
align="end"
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
onValueChange={handleOnValueChange}
options={configs
.filter((config) => !config.isArchived)
.map((config) => ({
key: config.id,
value: `${getScoreDataTypeIcon(config.dataType)} ${config.name}`,
isArchived: config.isArchived,
}))}
values={field.value.map((configId) => {
const config = configs.find(
(config) => config.id === configId,
);
return {
value: config
? `${getScoreDataTypeIcon(config.dataType)} ${config.name}`
: `${configId}`,
key: configId,
};
})}
controlButtons={
<DropdownMenuItem
onSelect={() => {
capture(
"score_configs:manage_configs_item_click",
{ source: "AnnotationQueue" },
);
router.push(
`/project/${projectId}/settings/scores`,
);
}}
>
Manage score configs
</DropdownMenuItem>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="text-xs"
disabled={!!form.formState.errors.name}
>
{queueId ? "Save" : "Create"} queue
</Button>
<DialogBody>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
type="text"
className="text-xs"
onBlur={(e) =>
field.onChange(e.target.value.trimEnd())
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Add description..."
className="text-xs focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scoreConfigIds"
render={({ field }) => (
<FormItem>
<FormLabel>Score Configs</FormLabel>
<FormDescription>
Define which dimensions annotators should score for the
given queue.
</FormDescription>
<FormControl>
<MultiSelectKeyValues
placeholder="Value"
align="end"
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
onValueChange={handleOnValueChange}
options={configs
.filter((config) => !config.isArchived)
.map((config) => ({
key: config.id,
value: `${getScoreDataTypeIcon(config.dataType)} ${config.name}`,
isArchived: config.isArchived,
}))}
values={field.value.map((configId) => {
const config = configs.find(
(config) => config.id === configId,
);
return {
value: config
? `${getScoreDataTypeIcon(config.dataType)} ${config.name}`
: `${configId}`,
key: configId,
};
})}
controlButtons={
<DropdownMenuItem
onSelect={() => {
capture(
"score_configs:manage_configs_item_click",
{ source: "AnnotationQueue" },
);
router.push(
`/project/${projectId}/settings/scores`,
);
}}
>
Manage score configs
</DropdownMenuItem>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</DialogBody>
<DialogFooter>
<Button
type="submit"
className="text-xs"
disabled={!!form.formState.errors.name}
>
{queueId ? "Save" : "Create"} queue
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
@@ -3,6 +3,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -56,7 +57,7 @@ export const DeleteAnnotationQueueButton = ({
}}
>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogContent className="overflow-hidden sm:max-w-xl">
<DialogHeader>
<DialogTitle className="mb-4">Please confirm</DialogTitle>
<DialogDescription className="text-md p-0">
@@ -65,21 +66,23 @@ export const DeleteAnnotationQueueButton = ({
deleted.
</DialogDescription>
</DialogHeader>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
await mutDelete.mutateAsync({
projectId,
queueId,
});
setIsOpen(false);
}}
>
Delete Annotation Queue
</Button>
<DialogFooter>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
await mutDelete.mutateAsync({
projectId,
queueId,
});
setIsOpen(false);
}}
>
Delete Annotation Queue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
@@ -9,6 +9,7 @@ import {
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
@@ -133,35 +134,37 @@ const DataRegionInfo = () => (
<DialogHeader>
<DialogTitle>Data Regions</DialogTitle>
</DialogHeader>
<DialogDescription className="flex flex-col gap-2">
<p>Langfuse Cloud is available in two data regions:</p>
<ul className="list-disc pl-5">
<li>US: Oregon (AWS us-west-2)</li>
<li>EU: Ireland (AWS eu-west-1)</li>
</ul>
<p>
Regions are strictly separated, and no data is shared across regions.
Choosing a region close to you can help improve speed and comply with
local data residency laws and privacy regulations. Contact us to
onboard into a HIPAA compliant region.
</p>
<p>
You can have accounts in both regions and data migrations are
available on Team plans.
</p>
<p>
For more information, visit{" "}
<a
href="https://langfuse.com/docs/data-security-privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary-accent underline"
>
langfuse.com/security
</a>
.
</p>
</DialogDescription>
<DialogBody>
<DialogDescription className="flex flex-col gap-2">
<p>Langfuse Cloud is available in two data regions:</p>
<ul className="list-disc pl-5">
<li>US: Oregon (AWS us-west-2)</li>
<li>EU: Ireland (AWS eu-west-1)</li>
</ul>
<p>
Regions are strictly separated, and no data is shared across
regions. Choosing a region close to you can help improve speed and
comply with local data residency laws and privacy regulations.
Contact us to onboard into a HIPAA compliant region.
</p>
<p>
You can have accounts in both regions and data migrations are
available on Team plans.
</p>
<p>
For more information, visit{" "}
<a
href="https://langfuse.com/docs/data-security-privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary-accent underline"
>
langfuse.com/security
</a>
.
</p>
</DialogDescription>
</DialogBody>
</DialogContent>
</Dialog>
);
@@ -6,6 +6,7 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
DialogBody,
} from "@/src/components/ui/dialog";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
@@ -69,29 +70,29 @@ export function EditDashboardDialog({
<DialogHeader>
<DialogTitle>Edit Dashboard</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Dashboard name"
/>
<DialogBody>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Dashboard name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Dashboard description"
rows={3}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Dashboard description"
rows={3}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<div className="flex gap-2">
<Button
@@ -6,6 +6,7 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
DialogBody,
} from "@/src/components/ui/dialog";
import { Button } from "@/src/components/ui/button";
import {
@@ -67,52 +68,52 @@ export function SelectDashboardDialog({
<DialogHeader>
<DialogTitle>Select dashboard to add widget to</DialogTitle>
</DialogHeader>
<div className="mt-4 max-h-[400px] overflow-y-auto">
{dashboards.isLoading ? (
<div className="py-8 text-center">Loading dashboards...</div>
) : dashboards.isError ? (
<div className="py-8 text-center text-destructive">
Error: {dashboards.error.message}
</div>
) : dashboards.data?.dashboards.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No dashboards found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.data?.dashboards
.filter((d) => d.owner === "PROJECT")
.map((d) => (
<TableRow
key={d.id}
onClick={() => setSelectedDashboardId(d.id)}
className={`cursor-pointer hover:bg-muted ${
selectedDashboardId === d.id ? "bg-muted" : ""
}`}
>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="truncate" title={d.description}>
{d.description}
</TableCell>
<TableCell>
{new Date(d.updatedAt).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogBody>
<div className="mt-4 max-h-[400px] overflow-y-auto">
{dashboards.isLoading ? (
<div className="py-8 text-center">Loading dashboards...</div>
) : dashboards.isError ? (
<div className="py-8 text-center text-destructive">
Error: {dashboards.error.message}
</div>
) : dashboards.data?.dashboards.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No dashboards found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboards.data?.dashboards
.filter((d) => d.owner === "PROJECT")
.map((d) => (
<TableRow
key={d.id}
onClick={() => setSelectedDashboardId(d.id)}
className={`cursor-pointer hover:bg-muted ${
selectedDashboardId === d.id ? "bg-muted" : ""
}`}
>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="truncate" title={d.description}>
{d.description}
</TableCell>
<TableCell>
{new Date(d.updatedAt).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogBody>
<DialogFooter className="mt-4 flex justify-between">
<Button variant="outline" onClick={handleSkip}>
Skip
@@ -326,10 +326,9 @@ export async function executeQuery(
query: QueryType,
): Promise<Array<Record<string, unknown>>> {
try {
const { query: compiledQuery, parameters } = new QueryBuilder().build(
query,
projectId,
);
const { query: compiledQuery, parameters } = new QueryBuilder(
query.chartConfig,
).build(query, projectId);
const result = await queryClickhouse<Record<string, unknown>>({
query: compiledQuery,
@@ -146,6 +146,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</DialogDescription>
)}
</DialogHeader>
{props.mode === "create" ? (
<DatasetForm
mode="create"
@@ -5,6 +5,7 @@ import { Button } from "@/src/components/ui/button";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import {
Dialog,
DialogBody,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -188,33 +189,34 @@ const DatasetAggregateCell = ({
</DialogTrigger>
<DialogContent
className="max-w-screen-xl"
size="xl"
onClick={(event) => event.stopPropagation()}
>
<DialogHeader>
<DialogTitle>Expected Output Actual Output</DialogTitle>
</DialogHeader>
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
<div className="space-y-6">
<div className="space-y-4">
<div>
<DiffViewer
oldString={JSON.stringify(output, null, 2)}
newString={JSON.stringify(
data?.output ?? "null",
null,
2,
)}
oldLabel="Expected Output"
newLabel="Actual Output"
/>
<DialogBody>
<div className="max-h-[80vh] max-w-screen-xl space-y-6 overflow-y-auto">
<div className="space-y-6">
<div className="space-y-4">
<div>
<DiffViewer
oldString={JSON.stringify(output, null, 2)}
newString={JSON.stringify(
data?.output ?? "null",
null,
2,
)}
oldLabel="Expected Output"
newLabel="Actual Output"
/>
</div>
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter className="flex flex-row">
<DialogFooter className="static mt-auto">
<Button
onClick={() => {
setIsOpen(false);
@@ -2,7 +2,6 @@ import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
import { RESOURCE_METRICS } from "@/src/features/dashboard/lib/score-analytics-utils";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { MultiSelectKeyValues } from "@/src/features/scores/components/multi-select-key-values";
import { ChartLine } from "lucide-react";
export function DatasetAnalytics(props: {
projectId: string;
@@ -13,12 +12,12 @@ export function DatasetAnalytics(props: {
const capture = usePostHogClientCapture();
return (
<MultiSelectKeyValues
className="max-w-full focus:!ring-0 focus:!ring-offset-0"
className="max-w-fit focus:!ring-0 focus:!ring-offset-0"
placeholder="Search..."
title="Selected"
variant="ghost"
iconLeft={<ChartLine className="mr-1 h-4 w-4" />}
title="Charts"
variant="outline"
hideClearButton
showSelectedValueStrings={false}
onValueChange={(values, changedValue, selectedKeys) => {
if (values.length === 0) props.setSelectedMetrics([]);
@@ -19,6 +19,7 @@ import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePos
import { Label } from "@/src/components/ui/label";
import { useRouter } from "next/router";
import { useUniqueNameValidation } from "@/src/hooks/useUniqueNameValidation";
import { DialogBody, DialogFooter } from "@/src/components/ui/dialog";
interface BaseDatasetFormProps {
mode: "create" | "update" | "delete";
@@ -202,90 +203,94 @@ export const DatasetForm = (props: DatasetFormProps) => {
props.mode === "delete" ? handleDelete : form.handleSubmit(onSubmit)
}
>
{props.mode === "delete" ? (
<div className="mb-8 grid w-full gap-1.5">
<Label htmlFor="delete-confirmation">
Type &quot;{props.datasetName}&quot; to confirm deletion
</Label>
<Input
id="delete-confirmation"
value={deleteConfirmationInput}
onChange={(e) => setDeleteConfirmationInput(e.target.value)}
/>
</div>
) : (
<div className="mb-8 space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem>
<FormLabel>Metadata (optional)</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={(v) => {
field.onChange(v);
}}
minHeight="none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<Button
type="submit"
variant={props.mode === "delete" ? "destructive" : "default"}
disabled={!!form.formState.errors.name}
loading={
(props.mode === "create" && createMutation.isLoading) ||
(props.mode === "delete" && deleteMutation.isLoading)
}
className="w-full"
>
{props.mode === "create"
? "Create dataset"
: props.mode === "delete"
? "Delete Dataset"
: "Update dataset"}
</Button>
<DialogBody>
{props.mode === "delete" ? (
<div className="mb-8 grid w-full gap-1.5">
<Label htmlFor="delete-confirmation">
Type &quot;{props.datasetName}&quot; to confirm deletion
</Label>
<Input
id="delete-confirmation"
value={deleteConfirmationInput}
onChange={(e) => setDeleteConfirmationInput(e.target.value)}
/>
</div>
) : (
<div className="mb-8 space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem>
<FormLabel>Metadata (optional)</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={(v) => {
field.onChange(v);
}}
minHeight="none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</DialogBody>
<DialogFooter>
<Button
type="submit"
variant={props.mode === "delete" ? "destructive" : "default"}
disabled={!!form.formState.errors.name}
loading={
(props.mode === "create" && createMutation.isLoading) ||
(props.mode === "delete" && deleteMutation.isLoading)
}
className="w-full"
>
{props.mode === "create"
? "Create dataset"
: props.mode === "delete"
? "Delete Dataset"
: "Update dataset"}
</Button>
{formError && (
<p className="mt-4 text-center text-sm text-red-500">
<span className="font-bold">Error:</span> {formError}
</p>
)}
</DialogFooter>
</form>
</Form>
{formError && (
<p className="mt-4 text-center text-sm text-red-500">
<span className="font-bold">Error:</span> {formError}
</p>
)}
</div>
);
};
@@ -11,7 +11,10 @@ import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context
import { useEffect, useMemo } from "react";
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
import { cn } from "@/src/utils/tailwind";
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
import {
IOTableCell,
MemoizedIOTableCell,
} from "@/src/components/ui/CodeJsonViewer";
import { ListTree } from "lucide-react";
import {
getScoreGroupColumnProps,
@@ -348,12 +351,8 @@ const TraceObservationIOCell = ({
{ traceId, projectId, fromTimestamp: fromTimestampModified },
{
enabled: observationId === undefined,
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
staleTime: 60 * 1000, // 1 minute
onError: () => {},
},
);
@@ -365,12 +364,8 @@ const TraceObservationIOCell = ({
},
{
enabled: observationId !== undefined,
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false, // prevents refetching loops
staleTime: 60 * 1000, // 1 minute
onError: () => {},
},
);
@@ -378,7 +373,7 @@ const TraceObservationIOCell = ({
const data = observationId === undefined ? trace.data : observation.data;
return (
<IOTableCell
<MemoizedIOTableCell
isLoading={
(!!!observationId ? trace.isLoading : observation.isLoading) || !data
}
@@ -49,6 +49,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
@@ -151,22 +152,24 @@ const DatasetRunTableMultiSelectAction = ({
{selectedRunIds.length > 1 ? "s" : ""}.
</DialogDescription>
</DialogHeader>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetRunIds: selectedRunIds,
});
setIsDeleteDialogOpen(false);
}}
>
Delete Dataset Runs
</Button>
<DialogFooter>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetRunIds: selectedRunIds,
});
setIsDeleteDialogOpen(false);
}}
>
Delete Dataset Runs
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
@@ -4,6 +4,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -69,22 +70,24 @@ export const DeleteDatasetRunButton = ({
deleted manually.
</DialogDescription>
</DialogHeader>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetRunIds: [datasetRunId],
});
setIsDialogOpen(false);
}}
>
Delete Dataset Run
</Button>
<DialogFooter>
<Button
variant="destructive"
loading={mutDelete.isLoading}
disabled={mutDelete.isLoading}
onClick={async (event) => {
event.preventDefault();
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetRunIds: [datasetRunId],
});
setIsDialogOpen(false);
}}
>
Delete Dataset Run
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
@@ -36,7 +36,7 @@ export const NewDatasetItemButton = (props: {
New item
</ActionButton>
</DialogTrigger>
<DialogContent className="h-[calc(100vh-5rem)] max-h-none w-[calc(100vw-5rem)] max-w-none items-start">
<DialogContent size="xl">
<DialogHeader>
<DialogTitle>Create new dataset item</DialogTitle>
</DialogHeader>
@@ -31,6 +31,7 @@ import {
import { Check, ChevronsUpDown } from "lucide-react";
import { Badge } from "@/src/components/ui/badge";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { DialogBody, DialogFooter } from "@/src/components/ui/dialog";
const formSchema = z.object({
datasetIds: z.array(z.string()).min(1, "Select at least one dataset"),
@@ -153,128 +154,152 @@ export const NewDatasetItemForm = (props: {
onSubmit={form.handleSubmit(onSubmit)}
className={cn("flex h-full flex-col gap-6", props.className)}
>
<div className="flex-none">
<FormField
control={form.control}
name="datasetIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Datasets</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value.length && "text-muted-foreground",
)}
>
{field.value.length > 0
? `${field.value.length} dataset${field.value.length > 1 ? "s" : ""} selected`
: "Select datasets"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<InputCommand>
<InputCommandInput placeholder="Search datasets..." />
<InputCommandEmpty>No datasets found.</InputCommandEmpty>
<InputCommandGroup>
<ScrollArea className="h-60">
{datasets.data
?.filter(
(dataset) =>
!props.blockedDatasetIds?.includes(dataset.id),
)
.map((dataset) => (
<InputCommandItem
value={dataset.name}
key={dataset.id}
onSelect={() => {
const newValue = field.value.includes(
dataset.id,
)
? field.value.filter(
(id) => id !== dataset.id,
)
: [...field.value, dataset.id];
field.onChange(newValue);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value.includes(dataset.id)
? "opacity-100"
: "opacity-0",
)}
/>
{dataset.name}
</InputCommandItem>
))}
</ScrollArea>
</InputCommandGroup>
</InputCommand>
</PopoverContent>
</Popover>
{field.value.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{field.value.map((datasetId) => {
const dataset = datasets.data?.find(
(d) => d.id === datasetId,
);
return (
<Badge
key={datasetId}
variant="secondary"
className="mb-1 mr-1"
>
{dataset?.name || datasetId}
</Badge>
);
})}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="ph-no-capture min-h-0 flex-1 overflow-y-auto">
<div className="grid gap-4 md:grid-cols-2">
<DialogBody className="grid grid-rows-[auto,1fr]">
<div className="flex-none">
<FormField
control={form.control}
name="input"
name="datasetIds"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Input</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={field.onChange}
minHeight={200}
/>
</FormControl>
<FormItem className="flex flex-col">
<FormLabel>Datasets</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value.length && "text-muted-foreground",
)}
>
{field.value.length > 0
? `${field.value.length} dataset${field.value.length > 1 ? "s" : ""} selected`
: "Select datasets"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<InputCommand>
<InputCommandInput placeholder="Search datasets..." />
<InputCommandEmpty>
No datasets found.
</InputCommandEmpty>
<InputCommandGroup>
<ScrollArea className="h-fit">
{datasets.data
?.filter(
(dataset) =>
!props.blockedDatasetIds?.includes(
dataset.id,
),
)
.map((dataset) => (
<InputCommandItem
value={dataset.name}
key={dataset.id}
onSelect={() => {
const newValue = field.value.includes(
dataset.id,
)
? field.value.filter(
(id) => id !== dataset.id,
)
: [...field.value, dataset.id];
field.onChange(newValue);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value.includes(dataset.id)
? "opacity-100"
: "opacity-0",
)}
/>
{dataset.name}
</InputCommandItem>
))}
</ScrollArea>
</InputCommandGroup>
</InputCommand>
</PopoverContent>
</Popover>
{field.value.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{field.value.map((datasetId) => {
const dataset = datasets.data?.find(
(d) => d.id === datasetId,
);
return (
<Badge
key={datasetId}
variant="secondary"
className="mb-1 mr-1"
>
{dataset?.name || datasetId}
</Badge>
);
})}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="ph-no-capture min-h-0 flex-1 overflow-y-auto">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="input"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Input</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={field.onChange}
minHeight={200}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expectedOutput"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Expected output</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={field.onChange}
minHeight={200}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="expectedOutput"
name="metadata"
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel>Expected output</FormLabel>
<FormItem className="mt-4 flex flex-col gap-2">
<FormLabel>Metadata</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={field.onChange}
minHeight={200}
minHeight={100}
/>
</FormControl>
<FormMessage />
@@ -282,26 +307,8 @@ export const NewDatasetItemForm = (props: {
)}
/>
</div>
<FormField
control={form.control}
name="metadata"
render={({ field }) => (
<FormItem className="mt-4 flex flex-col gap-2">
<FormLabel>Metadata</FormLabel>
<FormControl>
<CodeMirrorEditor
mode="json"
value={field.value}
onChange={field.onChange}
minHeight={100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-3 flex-none">
</DialogBody>
<DialogFooter>
<Button
type="submit"
loading={createManyDatasetItemsMutation.isLoading}
@@ -315,7 +322,7 @@ export const NewDatasetItemForm = (props: {
<span className="font-bold">Error:</span> {formError}
</p>
) : null}
</div>
</DialogFooter>
</form>
</Form>
);
@@ -22,6 +22,7 @@ import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { MAX_FILE_SIZE_BYTES } from "@/src/features/datasets/components/UploadDatasetCsv";
import { Progress } from "@/src/components/ui/progress";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { DialogBody, DialogFooter } from "@/src/components/ui/dialog";
const MIN_CHUNK_SIZE = 1;
const CHUNK_START_SIZE = 50;
@@ -330,131 +331,139 @@ export function PreviewCsvImport({
};
return (
<Card className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-hidden p-2">
<CardHeader className="shrink-0 text-center">
<CardTitle className="text-lg">Import {preview.fileName}</CardTitle>
<CardDescription>
Map your CSV columns to dataset fields. The CSV file must have column
headers in the first row.
</CardDescription>
</CardHeader>
<CardContent className="flex min-h-0 w-full flex-1 flex-col p-2">
<div className="min-h-0 flex-1">
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="grid h-full grid-cols-2 gap-4 lg:grid-cols-4">
<ImportCard
id="input"
title="Input"
columns={preview.columns.filter((col) =>
selectedInputColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedInputColumn(
new Set([...selectedInputColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedInputColumn(
new Set(
[...selectedInputColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="expected"
title="Expected Output"
columns={preview.columns.filter((col) =>
selectedExpectedColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedExpectedColumn(
new Set([...selectedExpectedColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedExpectedColumn(
new Set(
[...selectedExpectedColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="metadata"
title="Metadata"
columns={preview.columns.filter((col) =>
selectedMetadataColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedMetadataColumn(
new Set([...selectedMetadataColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedMetadataColumn(
new Set(
[...selectedMetadataColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="unmapped"
title="Not mapped"
info="These columns from your CSV will not be imported. Drag them to a field to include them."
columns={preview.columns.filter((col) =>
excludedColumns.has(col.name),
)}
onColumnSelect={(columnName) => {
setExcludedColumns(new Set([...excludedColumns, columnName]));
}}
onColumnRemove={(columnName) => {
setExcludedColumns(
new Set(
[...excludedColumns].filter((col) => col !== columnName),
),
);
}}
className="bg-secondary/50"
/>
<>
<DialogBody className="border-t">
<Card className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-hidden border-none pb-4">
<CardHeader className="shrink-0 text-center">
<CardTitle className="text-lg">Import {preview.fileName}</CardTitle>
<CardDescription>
Map your CSV columns to dataset fields. The CSV file must have
column headers in the first row.
</CardDescription>
</CardHeader>
<CardContent className="flex min-h-0 w-full flex-1 flex-col p-2">
<div className="min-h-0 flex-1">
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="grid h-full grid-cols-2 gap-4 lg:grid-cols-4">
<ImportCard
id="input"
title="Input"
columns={preview.columns.filter((col) =>
selectedInputColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedInputColumn(
new Set([...selectedInputColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedInputColumn(
new Set(
[...selectedInputColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="expected"
title="Expected Output"
columns={preview.columns.filter((col) =>
selectedExpectedColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedExpectedColumn(
new Set([...selectedExpectedColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedExpectedColumn(
new Set(
[...selectedExpectedColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="metadata"
title="Metadata"
columns={preview.columns.filter((col) =>
selectedMetadataColumn.has(col.name),
)}
onColumnSelect={(columnName) => {
setSelectedMetadataColumn(
new Set([...selectedMetadataColumn, columnName]),
);
}}
onColumnRemove={(columnName) => {
setSelectedMetadataColumn(
new Set(
[...selectedMetadataColumn].filter(
(col) => col !== columnName,
),
),
);
}}
/>
<ImportCard
id="unmapped"
title="Not mapped"
info="These columns from your CSV will not be imported. Drag them to a field to include them."
columns={preview.columns.filter((col) =>
excludedColumns.has(col.name),
)}
onColumnSelect={(columnName) => {
setExcludedColumns(
new Set([...excludedColumns, columnName]),
);
}}
onColumnRemove={(columnName) => {
setExcludedColumns(
new Set(
[...excludedColumns].filter(
(col) => col !== columnName,
),
),
);
}}
className="bg-secondary/50"
/>
</div>
</DndContext>
</div>
</DndContext>
</div>
<div className="mt-3 flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => {
setPreview(null);
setSelectedInputColumn(new Set());
setSelectedExpectedColumn(new Set());
setSelectedMetadataColumn(new Set());
setExcludedColumns(new Set());
setCsvFile(null);
}}
>
Cancel
</Button>
<Button
disabled={
selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0
}
onClick={handleImport}
>
Import
</Button>
</div>
</CardContent>
</Card>
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setPreview(null);
setSelectedInputColumn(new Set());
setSelectedExpectedColumn(new Set());
setSelectedMetadataColumn(new Set());
setExcludedColumns(new Set());
setCsvFile(null);
}}
>
Cancel
</Button>
<Button
disabled={
selectedInputColumn.size === 0 &&
selectedExpectedColumn.size === 0 &&
selectedMetadataColumn.size === 0
}
onClick={handleImport}
>
Import
</Button>
{progress.status === "processing" && (
<div className="mt-2">
<Progress
@@ -463,7 +472,7 @@ export function PreviewCsvImport({
/>
</div>
)}
</CardContent>
</Card>
</DialogFooter>
</>
);
}
@@ -14,6 +14,7 @@ import {
type CsvPreviewResult,
parseCsvClient,
} from "@/src/features/datasets/lib/csvHelpers";
import { DialogBody } from "@/src/components/ui/dialog";
export const MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1 * 10; // 10MB
const ACCEPTED_FILE_TYPES = ["text/csv"] as const;
@@ -76,35 +77,37 @@ export const UploadDatasetCsv = ({
};
return (
<Card className="h-full items-center justify-center p-2">
<CardHeader className="text-center">
<CardTitle className="text-lg">Add items to dataset</CardTitle>
<CardDescription>
Add items to dataset by uploading a file, add items manually or via
our SDKs/API
</CardDescription>
</CardHeader>
<CardContent>
{/* Hidden file input */}
<Input
type="file"
ref={fileInputRef}
className="hidden"
accept=".csv"
onChange={handleFileSelect}
/>
<DialogBody className="border-t">
<Card className="h-full items-center justify-center border-none">
<CardHeader className="text-center">
<CardTitle className="text-lg">Add items to dataset</CardTitle>
<CardDescription>
Add items to dataset by uploading a file, add items manually or via
our SDKs/API
</CardDescription>
</CardHeader>
<CardContent>
{/* Hidden file input */}
<Input
type="file"
ref={fileInputRef}
className="hidden"
accept=".csv"
onChange={handleFileSelect}
/>
{/* Clickable upload area */}
<div
className="flex max-h-full min-h-0 w-full cursor-pointer flex-col items-center justify-center gap-2 overflow-y-auto rounded-lg border border-dashed bg-secondary/50 p-4"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="h-6 w-6 text-secondary-foreground" />
<div className="text-sm text-secondary-foreground">
Click to select a CSV file
{/* Clickable upload area */}
<div
className="flex max-h-full min-h-0 w-full cursor-pointer flex-col items-center justify-center gap-2 overflow-y-auto rounded-lg border border-dashed bg-secondary/50 p-4"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="h-6 w-6 text-secondary-foreground" />
<div className="text-sm text-secondary-foreground">
Click to select a CSV file
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</DialogBody>
);
};
@@ -122,6 +122,16 @@ export const entitlementAccess: Record<
"prompt-management-count-prompts": false,
},
},
"self-hosted:pro": {
entitlements: selfHostedAllPlansEntitlements,
entitlementLimits: {
"annotation-queue-count": false,
"organization-member-count": false,
"data-access-days": false,
"model-based-evaluations-count-evaluators": false,
"prompt-management-count-prompts": false,
},
},
"self-hosted:enterprise": {
entitlements: [
...selfHostedAllPlansEntitlements,
@@ -60,5 +60,8 @@ export function getSelfHostedInstancePlanServerSide(): Plan | null {
if (licenseKey.startsWith("langfuse_ee_")) {
return "self-hosted:enterprise";
}
if (licenseKey.startsWith("langfuse_pro_")) {
return "self-hosted:pro";
}
return null;
}
@@ -377,7 +377,9 @@ export default function EvalsTemplateTable({
}}
>
<DialogContent className="max-h-[90vh] max-w-screen-md overflow-y-auto">
<DialogTitle>Edit evaluator</DialogTitle>
<DialogHeader>
<DialogTitle>Edit evaluator</DialogTitle>
</DialogHeader>
<EvalTemplateForm
projectId={projectId}
preventRedirect={true}
@@ -404,7 +406,9 @@ export default function EvalsTemplateTable({
}}
>
<DialogContent className="max-h-[90vh] max-w-screen-md overflow-y-auto">
<DialogTitle>Clone evaluator</DialogTitle>
<DialogHeader>
<DialogTitle>Clone evaluator</DialogTitle>
</DialogHeader>
<EvalTemplateForm
projectId={projectId}
preventRedirect={true}
@@ -35,7 +35,12 @@ import {
} from "@/src/components/ui/dropdown-menu";
import { Button } from "@/src/components/ui/button";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { EvaluatorForm } from "@/src/features/evals/components/evaluator-form";
import { useRouter } from "next/router";
import { DeleteEvalConfigButton } from "@/src/components/deleteButton";
@@ -419,7 +424,9 @@ export default function EvaluatorTable({ projectId }: { projectId: string }) {
}}
>
<DialogContent className="max-h-[90vh] max-w-screen-xl overflow-y-auto">
<DialogTitle>Edit configuration</DialogTitle>
<DialogHeader>
<DialogTitle>Edit configuration</DialogTitle>
</DialogHeader>
{existingEvaluator.isLoading ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />

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