Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65118f5f3a | ||
|
|
6ab82c539a | ||
|
|
20a6a707fb | ||
|
|
99880810fd | ||
|
|
fbfa871ed7 | ||
|
|
46ae185dcc | ||
|
|
d4c4de7401 | ||
|
|
8a7b32e925 | ||
|
|
2c5977cb4c | ||
|
|
1affa965d1 | ||
|
|
150afecd29 | ||
|
|
78c1c89625 | ||
|
|
3fa06c8365 | ||
|
|
91b1fa5848 | ||
|
|
4f5671429e | ||
|
|
5d0fa46657 | ||
|
|
8d1a2ff0e7 | ||
|
|
cce3bf35e1 | ||
|
|
eccfec36e8 | ||
|
|
06255bd325 | ||
|
|
a71faa4e60 | ||
|
|
b7362258c6 | ||
|
|
5eb1d98985 | ||
|
|
1c574b2a5d | ||
|
|
6db9b87b6a | ||
|
|
3e3ccb65ff | ||
|
|
0d53b443e5 | ||
|
|
cbf73a95a5 | ||
|
|
ebfeef32aa | ||
|
|
74dddbae00 | ||
|
|
1b795a3dcf | ||
|
|
2e94bc9e09 | ||
|
|
8b94e8a3bd | ||
|
|
91730a2eaf | ||
|
|
4f1a2337be | ||
|
|
603837ad3a | ||
|
|
2d3f339f12 | ||
|
|
02605eb268 | ||
|
|
4eed2d7000 | ||
|
|
4cf5aa03ec | ||
|
|
9e730e3c61 | ||
|
|
ba7c7b9526 | ||
|
|
8b1b389fc0 | ||
|
|
f7fc206bd0 | ||
|
|
dd2b1f697d | ||
|
|
7c799444d0 | ||
|
|
8afdb8db97 | ||
|
|
922eafd4f6 | ||
|
|
bc4acd453c | ||
|
|
9067f80206 | ||
|
|
a7ca1fb269 | ||
|
|
b51f321d22 | ||
|
|
dd048a64a0 | ||
|
|
c6c19de0e8 | ||
|
|
5491f176e1 | ||
|
|
64d5ebfa70 | ||
|
|
7a28473ebb | ||
|
|
005ce7318c | ||
|
|
448c56e114 | ||
|
|
1d6e498e2b | ||
|
|
33ab717ae0 | ||
|
|
ec563b775b | ||
|
|
de427b5572 | ||
|
|
95921fa5da | ||
|
|
9e4d352366 | ||
|
|
02449cbe0a | ||
|
|
616c68a87b | ||
|
|
319b22ae78 |
@@ -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"
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.65.3",
|
||||
"version": "3.67.1-2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'HISTOGRAM';
|
||||
@@ -1156,6 +1156,7 @@ enum DashboardWidgetChartType {
|
||||
VERTICAL_BAR
|
||||
PIE
|
||||
NUMBER
|
||||
HISTOGRAM
|
||||
}
|
||||
|
||||
model DashboardWidget {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1253
-981
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+31
-27
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 @@
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
+105
-97
@@ -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 "{props.datasetName}" 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 "{props.datasetName}" 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
Reference in New Issue
Block a user