Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd98d2cd8 | ||
|
|
23300f9db8 | ||
|
|
5427d3f74f | ||
|
|
a43fbc8a74 | ||
|
|
8d9c059572 | ||
|
|
37002f39bd | ||
|
|
c7de984fb1 | ||
|
|
1d3ad16710 | ||
|
|
17a358a1c2 | ||
|
|
7e76fbfcb4 | ||
|
|
aa5b5fb8a9 | ||
|
|
c084d19c2d | ||
|
|
23e765fc35 | ||
|
|
f60286f13d | ||
|
|
0f43240c35 | ||
|
|
dc4e94a68b | ||
|
|
fd98cd4cd7 | ||
|
|
a1f09a2e30 | ||
|
|
decac0cd5a | ||
|
|
17e600f65e | ||
|
|
0540828e21 | ||
|
|
4f9d7b0e60 | ||
|
|
a940fa40d9 | ||
|
|
ab2007a627 | ||
|
|
9b8868ac97 | ||
|
|
4f6e917cd7 | ||
|
|
da51c01f35 | ||
|
|
4aeea98b56 | ||
|
|
917e35a1b3 | ||
|
|
2e0a548e6f | ||
|
|
f9222c526f | ||
|
|
5cb61d6a96 | ||
|
|
463ada23ca | ||
|
|
b2d22a0720 | ||
|
|
fd1f1786da | ||
|
|
56598597df | ||
|
|
ea71c40ecb | ||
|
|
20cc20f908 | ||
|
|
6326a6d788 | ||
|
|
286f7ea29f | ||
|
|
700921f69f | ||
|
|
9e3379f05f | ||
|
|
bc178010c1 | ||
|
|
47a1cb24a3 | ||
|
|
312d97f509 | ||
|
|
7776462c9b | ||
|
|
aa8eabbc19 | ||
|
|
f4caacabdb | ||
|
|
7892ff521d | ||
|
|
45215cb4ff | ||
|
|
5404119473 | ||
|
|
7769cb9d2a | ||
|
|
0b68c410cf | ||
|
|
efa6fe7e6a | ||
|
|
16f41f809a | ||
|
|
a7d59d7bb2 | ||
|
|
291abf3205 | ||
|
|
5df5a7cd48 | ||
|
|
ac2936d316 | ||
|
|
0335dcd18d | ||
|
|
27cfbb14a1 | ||
|
|
4c5828dade | ||
|
|
2c9afcc58b | ||
|
|
d401d42377 | ||
|
|
322c767a33 | ||
|
|
53726d58bf | ||
|
|
63feb943a4 | ||
|
|
6379c7c54b | ||
|
|
69b3b81771 | ||
|
|
668d1b6933 | ||
|
|
d7069df433 | ||
|
|
83d3009331 | ||
|
|
688659c597 | ||
|
|
63ec00efe9 | ||
|
|
a11b5ffa92 | ||
|
|
72bf6a75c1 | ||
|
|
92b11374d9 | ||
|
|
828c3ff7be | ||
|
|
7b35cb0e99 | ||
|
|
8d5c445dc5 | ||
|
|
c752af2201 | ||
|
|
03f6204797 | ||
|
|
c33708e5db | ||
|
|
4c2c3d21c7 | ||
|
|
9a09fa2ad0 | ||
|
|
43e0644c64 | ||
|
|
c5be145a53 | ||
|
|
3a002f03d5 | ||
|
|
c785156220 | ||
|
|
6f8627ed86 | ||
|
|
6a7034a5ad | ||
|
|
29443bcdbd | ||
|
|
9f066a285f | ||
|
|
664bd3596e | ||
|
|
bad27d2625 | ||
|
|
4da02c1ebe | ||
|
|
65bd0bf5c1 | ||
|
|
b1ee21fc34 | ||
|
|
b894e8a620 | ||
|
|
aa39af94fb | ||
|
|
b1880ddb82 | ||
|
|
19e9da214e | ||
|
|
2e698c8ec0 | ||
|
|
46feb262ec | ||
|
|
23b830074a | ||
|
|
fa5908a749 | ||
|
|
26045d089c | ||
|
|
edfb15c010 | ||
|
|
1f76ae650f | ||
|
|
36dc608b58 | ||
|
|
bc0735729f | ||
|
|
88e553734e | ||
|
|
bd5495321e | ||
|
|
e8ebfb8e3e | ||
|
|
8c076b6b50 | ||
|
|
2cdc3cab7f | ||
|
|
729c2132af | ||
|
|
d3627bf225 | ||
|
|
05b87199ec | ||
|
|
a618d53451 | ||
|
|
9907c39861 | ||
|
|
4c8594e47a | ||
|
|
a69609229a | ||
|
|
217ae648d6 | ||
|
|
72e5b51398 | ||
|
|
0cbbf94568 | ||
|
|
61f4c3ac95 | ||
|
|
cc8f521741 | ||
|
|
540f8622e3 | ||
|
|
a7e3dec6f0 | ||
|
|
5760d51c08 | ||
|
|
59a75ce36c | ||
|
|
9a6a79f458 | ||
|
|
2598f44036 | ||
|
|
f7e7b132dd | ||
|
|
521be4e0b6 | ||
|
|
a13b5d0d32 | ||
|
|
ebe557881c | ||
|
|
5e8a9e3e80 | ||
|
|
ab115cdc19 | ||
|
|
18deee6434 | ||
|
|
977724a801 | ||
|
|
434c9be8a6 | ||
|
|
06bb3bbf43 | ||
|
|
736f4762bc | ||
|
|
4341b37ef3 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
[codespell]
|
||||
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma,pnpm-lock.yaml
|
||||
ignore-words-list = afterall,vertx,notIn,alue
|
||||
ignore-words-list = afterall,vertx,notIn,alue,allTime
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:20-bookworm
|
||||
|
||||
# Install golang-migrate for database migrations
|
||||
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && \
|
||||
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz && \
|
||||
chmod +x migrate && \
|
||||
mv migrate /usr/local/bin/migrate
|
||||
|
||||
|
||||
@@ -85,9 +85,3 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
# Use the following settings to enforce running the new AMTs during the tests
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES="true"
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_TRACES_TABLE="false"
|
||||
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT="true"
|
||||
|
||||
LANGFUSE_EXPERIMENT_USE_OTEL_INGESTION_QUEUE="true"
|
||||
|
||||
+13
-1
@@ -87,4 +87,16 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
# Slack credentials for development
|
||||
SLACK_CLIENT_ID=your_slack_client_id
|
||||
SLACK_CLIENT_SECRET=your_slack_client_secret
|
||||
SLACK_STATE_SECRET=your_slack_state_secret
|
||||
SLACK_STATE_SECRET=your_slack_state_secret
|
||||
|
||||
# Langfuse AI feature credentials for development
|
||||
LANGFUSE_AI_FEATURES_PUBLIC_KEY="pk-lf-123456789"
|
||||
LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-123456789"
|
||||
LANGFUSE_AI_FEATURES_HOST="https://staging.langfuse.com"
|
||||
|
||||
# Bedrock credentials for development
|
||||
AWS_ACCESS_KEY_ID="A123456789"
|
||||
AWS_SECRET_ACCESS_KEY="SAK123456789"
|
||||
LANGFUSE_AWS_BEDROCK_REGION="eu-west-1"
|
||||
|
||||
LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-5-sonnet-20240620-v1:0"
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: 'Service to be deployed'
|
||||
description: "Service to be deployed"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
- worker
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
description: "Environment to deploy to"
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@v3
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
@@ -350,6 +350,17 @@ jobs:
|
||||
run: pnpm --filter=worker... run build
|
||||
- name: run tests
|
||||
run: pnpm --filter=worker run test
|
||||
env:
|
||||
LANGFUSE_LLM_CONNECTION_OPENAI_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_OPENAI_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_ANTHROPIC_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_ANTHROPIC_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_AZURE_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_AZURE_BASE_URL: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_BASE_URL }}
|
||||
LANGFUSE_LLM_CONNECTION_AZURE_MODEL: ${{ secrets.LANGFUSE_LLM_CONNECTION_AZURE_MODEL }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_REGION: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_REGION }}
|
||||
LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY }}
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -381,7 +392,7 @@ jobs:
|
||||
cp .env.dev.example web/.env
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Run + migrate
|
||||
@@ -437,7 +448,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- name: Load default env
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
name: SDK API Spec Generation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'fern/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: sdk-api-spec-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate-sdk-api-specs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Generate SDK specs
|
||||
env:
|
||||
FERN_TOKEN: ${{ secrets.FERN_TOKEN }}
|
||||
run: npx fern-api generate --api server --force
|
||||
|
||||
- name: Update Python SDK
|
||||
env:
|
||||
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
run: |
|
||||
# Clone langfuse-python repository
|
||||
git clone https://github.com/langfuse/langfuse-python.git ../langfuse-python
|
||||
cd ../langfuse-python
|
||||
|
||||
git config user.name "langfuse-bot"
|
||||
git config user.email "langfuse-bot@langfuse.com"
|
||||
echo "$GH_ACCESS_TOKEN" | gh auth login --with-token
|
||||
gh auth setup-git
|
||||
|
||||
# Delete existing API contents
|
||||
rm -rf langfuse/api/*
|
||||
|
||||
# Copy generated Python SDK files
|
||||
cp -r ../langfuse/generated/python/* langfuse/api/
|
||||
|
||||
# Remove unnecessary integration tests created by Fern
|
||||
# (could not find the right option to prevent this in generation step)
|
||||
rm -rf langfuse/api/tests
|
||||
|
||||
# Install poetry and format
|
||||
pip install poetry
|
||||
poetry install --all-extras
|
||||
poetry run ruff format langfuse/api
|
||||
|
||||
# Check for changes and show diff for debugging
|
||||
if git diff --quiet; then
|
||||
echo "No changes detected in Python SDK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Close existing api-spec-bot PRs
|
||||
gh pr list --author langfuse-bot --state open --json number --jq '.[].number' | xargs -I {} gh pr close {}
|
||||
|
||||
# Get the GitHub username of the original commit author
|
||||
cd ../langfuse
|
||||
ORIGINAL_AUTHOR=$(gh api repos/langfuse/langfuse/commits/${GITHUB_SHA} --jq '.author.login')
|
||||
cd ../langfuse-python
|
||||
|
||||
# Create new branch and push changes
|
||||
BRANCH_NAME="api-spec-bot-${GITHUB_SHA::7}"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git add .
|
||||
git commit -m "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}"
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
# Create PR with original author as reviewer
|
||||
gh pr create --title "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --body "" --reviewer "$ORIGINAL_AUTHOR"
|
||||
|
||||
- name: Update TypeScript SDK
|
||||
env:
|
||||
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
run: |
|
||||
# Clone langfuse-js repository
|
||||
git clone https://github.com/langfuse/langfuse-js.git ../langfuse-js
|
||||
cd ../langfuse-js
|
||||
|
||||
# Configure git
|
||||
git config user.name "langfuse-bot"
|
||||
git config user.email "langfuse-bot@langfuse.com"
|
||||
echo "$GH_ACCESS_TOKEN" | gh auth login --with-token
|
||||
gh auth setup-git
|
||||
|
||||
# Delete existing API contents
|
||||
rm -rf packages/core/src/api/*
|
||||
|
||||
# Copy generated TypeScript SDK files
|
||||
cp -r ../langfuse/generated/typescript/* packages/core/src/api/
|
||||
|
||||
# Patch compilation error inducing output
|
||||
# Without this patch, the JS SDK will not build due to
|
||||
# "Error: packages/core build: src/api/core/auth/index.ts(1,10): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'."
|
||||
if [ -f "packages/core/src/api/core/auth/index.ts" ]; then
|
||||
if grep -q '^export { AuthProvider } from "./AuthProvider.js";$' packages/core/src/api/core/auth/index.ts; then
|
||||
sed -i '1s/^export { AuthProvider }/export { type AuthProvider }/' packages/core/src/api/core/auth/index.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install dependencies and format
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm format
|
||||
|
||||
# Check for changes and show diff for debugging
|
||||
if git diff --quiet; then
|
||||
echo "No changes detected in TypeScript SDK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Close existing api-spec-bot PRs
|
||||
gh pr list --author langfuse-bot --state open --json number --jq '.[].number' | xargs -I {} gh pr close {}
|
||||
|
||||
# Get the GitHub username of the original commit author
|
||||
cd ../langfuse
|
||||
ORIGINAL_AUTHOR=$(gh api repos/langfuse/langfuse/commits/${GITHUB_SHA} --jq '.author.login')
|
||||
cd ../langfuse-js
|
||||
|
||||
# Create new branch and push changes
|
||||
BRANCH_NAME="api-spec-bot-${GITHUB_SHA::7}"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git add .
|
||||
git commit -m "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --no-verify
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
# Create PR with original author as reviewer
|
||||
gh pr create --title "feat(api): update API spec from langfuse/langfuse ${GITHUB_SHA::7}" --body "" --reviewer "$ORIGINAL_AUTHOR"
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||
<img width="4856" height="1000" alt="github-banner" src="https://github.com/user-attachments/assets/6f435ef3-1194-4e26-87af-aa13826bbb5f" />
|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
@@ -76,7 +76,7 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
|
||||
|
||||
## ✨ Core Features
|
||||
|
||||

|
||||
<img width="4856" height="1944" alt="Langfuse Overview" src="https://github.com/user-attachments/assets/5dac68ef-d546-49fb-b06f-cfafc19282e3" />
|
||||
|
||||
- [LLM Application Observability](https://langfuse.com/docs/tracing): Instrument your app and start ingesting traces to Langfuse, thereby tracking LLM calls and other relevant logic in your app such as retrieval, embedding, or agent actions. Inspect and debug complex logs and user sessions. Try the interactive [demo](https://langfuse.com/docs/demo) to see this in action.
|
||||
|
||||
@@ -92,7 +92,7 @@ Langfuse is an **open source LLM engineering** platform. It helps teams collabor
|
||||
|
||||
## 📦 Deploy Langfuse
|
||||
|
||||

|
||||
<img width="4856" height="1322" alt="Langfuse Deployment Options" src="https://github.com/user-attachments/assets/98f020c7-7a20-4264-a201-65c41a52a5d5" />
|
||||
|
||||
### Langfuse Cloud
|
||||
|
||||
@@ -127,7 +127,7 @@ See [self-hosting documentation](https://langfuse.com/self-hosting) to learn mor
|
||||
|
||||
## 🔌 Integrations
|
||||
|
||||

|
||||
<img width="4856" height="1322" alt="github-integrations" src="https://github.com/user-attachments/assets/e41ea0fb-742d-41ce-bf94-1d4fb95750cd" />
|
||||
|
||||
### Main Integrations:
|
||||
|
||||
@@ -215,7 +215,7 @@ main()
|
||||
|
||||
See your language model calls and other application logic in Langfuse.
|
||||
|
||||

|
||||
<img width="1787" height="674" alt="Example trace in Langfuse" src="https://github.com/user-attachments/assets/f796eb78-dfb5-4570-b236-bdb4b67d4d55" />
|
||||
|
||||
_[Public example trace in Langfuse](https://cloud.langfuse.com/project/cloramnkj0002jz088vzn1ja4/traces/2cec01e3-3dc2-472f-afcf-3b968cf0c1f4?timestamp=2025-02-10T14%3A27%3A30.275Z&observation=cb5ff844-07ef-41e6-b8e2-6c64344bc13b)_
|
||||
|
||||
@@ -268,52 +268,105 @@ This repository is MIT licensed, except for the `ee` folders. See [LICENSE](LICE
|
||||
|
||||
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents)):
|
||||
|
||||
| Repository | Stars |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 54865 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 51531 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 49003 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 39093 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37368 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 32486 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 32448 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26931 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/119600397?s=40&v=4" width="20" height="20" alt=""> [twentyhq](https://github.com/twentyhq) / [twenty](https://github.com/twentyhq/twenty) | 24195 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22618 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 15151 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179202840?s=40&v=4" width="20" height="20" alt=""> [mediar-ai](https://github.com/mediar-ai) / [screenpipe](https://github.com/mediar-ai/screenpipe) | 11037 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105877416?s=40&v=4" width="20" height="20" alt=""> [formbricks](https://github.com/formbricks) / [formbricks](https://github.com/formbricks/formbricks) | 9386 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 8385 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7374 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5391 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 4976 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 4141 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2526 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2061 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1161 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1100 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1074 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 971 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 823 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 677 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 639 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 514 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 394 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 384 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 370 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 324 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 305 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/154247157?s=40&v=4" width="20" height="20" alt=""> [vespperhq](https://github.com/vespperhq) / [vespper](https://github.com/vespperhq/vespper) | 304 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> [block](https://github.com/block) / [goose](https://github.com/block/goose) | 295 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 291 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 221 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 172 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 148 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 138 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 135 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 129 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 128 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 126 |
|
||||
| Repository | Stars |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -----: |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 116251 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 109642 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/23818?s=40&v=4" width="20" height="20" alt=""> [abi](https://github.com/abi) / [screenshot-to-code](https://github.com/abi/screenshot-to-code) | 70877 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/131470832?s=40&v=4" width="20" height="20" alt=""> [lobehub](https://github.com/lobehub) / [lobe-chat](https://github.com/lobehub/lobe-chat) | 65454 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/69962740?s=40&v=4" width="20" height="20" alt=""> [infiniflow](https://github.com/infiniflow) / [ragflow](https://github.com/infiniflow/ragflow) | 64118 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/135057108?s=40&v=4" width="20" height="20" alt=""> [firecrawl](https://github.com/firecrawl) / [firecrawl](https://github.com/firecrawl/firecrawl) | 56713 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 44203 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/128289781?s=40&v=4" width="20" height="20" alt=""> [FlowiseAI](https://github.com/FlowiseAI) / [Flowise](https://github.com/FlowiseAI/Flowise) | 43547 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/159330290?s=40&v=4" width="20" height="20" alt=""> [QuivrHQ](https://github.com/QuivrHQ) / [quivr](https://github.com/QuivrHQ/quivr) | 38415 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/6154722?s=40&v=4" width="20" height="20" alt=""> [microsoft](https://github.com/microsoft) / [ai-agents-for-beginners](https://github.com/microsoft/ai-agents-for-beginners) | 38012 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> [chatchat-space](https://github.com/chatchat-space) / [Langchain-Chatchat](https://github.com/chatchat-space/Langchain-Chatchat) | 36071 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 35669 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 28726 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/157326433?s=40&v=4" width="20" height="20" alt=""> [onlook-dev](https://github.com/onlook-dev) / [onlook](https://github.com/onlook-dev/onlook) | 22447 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/487568?s=40&v=4" width="20" height="20" alt=""> [NixOS](https://github.com/NixOS) / [nixpkgs](https://github.com/NixOS/nixpkgs) | 21748 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/170767358?s=40&v=4" width="20" height="20" alt=""> [kortix-ai](https://github.com/kortix-ai) / [suna](https://github.com/kortix-ai/suna) | 17976 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/76263028?s=40&v=4" width="20" height="20" alt=""> [anthropics](https://github.com/anthropics) / [courses](https://github.com/anthropics/courses) | 17057 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/149120496?s=40&v=4" width="20" height="20" alt=""> [mastra-ai](https://github.com/mastra-ai) / [mastra](https://github.com/mastra-ai/mastra) | 16484 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> [langfuse](https://github.com/langfuse) / [langfuse](https://github.com/langfuse/langfuse) | 16054 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 11868 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137907881?s=40&v=4" width="20" height="20" alt=""> [promptfoo](https://github.com/promptfoo) / [promptfoo](https://github.com/promptfoo/promptfoo) | 8350 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/193350244?s=40&v=4" width="20" height="20" alt=""> [The-Pocket](https://github.com/The-Pocket) / [PocketFlow](https://github.com/The-Pocket/PocketFlow) | 8313 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139012218?s=40&v=4" width="20" height="20" alt=""> [OpenPipe](https://github.com/OpenPipe) / [ART](https://github.com/OpenPipe/ART) | 7093 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 7011 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/3299148?s=40&v=4" width="20" height="20" alt=""> [awslabs](https://github.com/awslabs) / [agent-squad](https://github.com/awslabs/agent-squad) | 6785 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/162546372?s=40&v=4" width="20" height="20" alt=""> [BasedHardware](https://github.com/BasedHardware) / [omi](https://github.com/BasedHardware/omi) | 6231 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/118301880?s=40&v=4" width="20" height="20" alt=""> [hatchet-dev](https://github.com/hatchet-dev) / [hatchet](https://github.com/hatchet-dev/hatchet) | 6019 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/88676955?s=40&v=4" width="20" height="20" alt=""> [zenml-io](https://github.com/zenml-io) / [zenml](https://github.com/zenml-io/zenml) | 4873 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132635806?s=40&v=4" width="20" height="20" alt=""> [refly-ai](https://github.com/refly-ai) / [refly](https://github.com/refly-ai/refly) | 4654 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/47287758?s=40&v=4" width="20" height="20" alt=""> [coleam00](https://github.com/coleam00) / [ottomator-agents](https://github.com/coleam00/ottomator-agents) | 4165 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8251002?s=40&v=4" width="20" height="20" alt=""> [JoshuaC215](https://github.com/JoshuaC215) / [agent-service-toolkit](https://github.com/JoshuaC215/agent-service-toolkit) | 3557 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185852128?s=40&v=4" width="20" height="20" alt=""> [colanode](https://github.com/colanode) / [colanode](https://github.com/colanode/colanode) | 3517 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/201282378?s=40&v=4" width="20" height="20" alt=""> [VoltAgent](https://github.com/VoltAgent) / [voltagent](https://github.com/VoltAgent/voltagent) | 3210 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> [bragai](https://github.com/bragai) / [bRAG-langchain](https://github.com/bragai/bRAG-langchain) | 3010 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 2651 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/135396723?s=40&v=4" width="20" height="20" alt=""> [sourcebot-dev](https://github.com/sourcebot-dev) / [sourcebot](https://github.com/sourcebot-dev/sourcebot) | 2570 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 2055 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/179994263?s=40&v=4" width="20" height="20" alt=""> [YFGaia](https://github.com/YFGaia) / [dify-plus](https://github.com/YFGaia/dify-plus) | 1734 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/46323662?s=40&v=4" width="20" height="20" alt=""> [TheSpaghettiDetective](https://github.com/TheSpaghettiDetective) / [obico-server](https://github.com/TheSpaghettiDetective/obico-server) | 1687 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1387 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/144196744?s=40&v=4" width="20" height="20" alt=""> [TIGER-AI-Lab](https://github.com/TIGER-AI-Lab) / [TheoremExplainAgent](https://github.com/TIGER-AI-Lab/TheoremExplainAgent) | 1385 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2314423?s=40&v=4" width="20" height="20" alt=""> [trailofbits](https://github.com/trailofbits) / [buttercup](https://github.com/trailofbits/buttercup) | 1223 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/62564480?s=40&v=4" width="20" height="20" alt=""> [wassim249](https://github.com/wassim249) / [fastapi-langgraph-agent-production-ready-template](https://github.com/wassim249/fastapi-langgraph-agent-production-ready-template) | 1200 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/18422723?s=40&v=4" width="20" height="20" alt=""> [alishobeiri](https://github.com/alishobeiri) / [thread](https://github.com/alishobeiri/thread) | 1098 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2357342?s=40&v=4" width="20" height="20" alt=""> [dmayboroda](https://github.com/dmayboroda) / [minima](https://github.com/dmayboroda/minima) | 1010 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/65890619?s=40&v=4" width="20" height="20" alt=""> [zstar1003](https://github.com/zstar1003) / [ragflow-plus](https://github.com/zstar1003/ragflow-plus) | 993 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121352441?s=40&v=4" width="20" height="20" alt=""> [openops-cloud](https://github.com/openops-cloud) / [openops](https://github.com/openops-cloud/openops) | 939 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 927 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/74420131?s=40&v=4" width="20" height="20" alt=""> [xataio](https://github.com/xataio) / [agent](https://github.com/xataio/agent) | 857 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/123981229?s=40&v=4" width="20" height="20" alt=""> [plastic-labs](https://github.com/plastic-labs) / [tutor-gpt](https://github.com/plastic-labs/tutor-gpt) | 845 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/176438967?s=40&v=4" width="20" height="20" alt=""> [trendy-design](https://github.com/trendy-design) / [llmchat](https://github.com/trendy-design/llmchat) | 829 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/2045570?s=40&v=4" width="20" height="20" alt=""> [hotovo](https://github.com/hotovo) / [aider-desk](https://github.com/hotovo/aider-desk) | 781 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 719 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/142796018?s=40&v=4" width="20" height="20" alt=""> [wrtnlabs](https://github.com/wrtnlabs) / [autoview](https://github.com/wrtnlabs/autoview) | 688 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 643 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 587 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 487 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/58037050?s=40&v=4" width="20" height="20" alt=""> [NicholasGoh](https://github.com/NicholasGoh) / [fastapi-mcp-langgraph-template](https://github.com/NicholasGoh/fastapi-mcp-langgraph-template) | 481 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/148684274?s=40&v=4" width="20" height="20" alt=""> [i-am-alice](https://github.com/i-am-alice) / [3rd-devs](https://github.com/i-am-alice/3rd-devs) | 472 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/163431636?s=40&v=4" width="20" height="20" alt=""> [AIDotNet](https://github.com/AIDotNet) / [koala-ai](https://github.com/AIDotNet/koala-ai) | 470 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> [phospho-app](https://github.com/phospho-app) / [text-analytics-legacy](https://github.com/phospho-app/text-analytics-legacy) | 439 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/170831637?s=40&v=4" width="20" height="20" alt=""> [inferablehq](https://github.com/inferablehq) / [inferable](https://github.com/inferablehq/inferable) | 403 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171800766?s=40&v=4" width="20" height="20" alt=""> [duoyang666](https://github.com/duoyang666) / [ai_novel](https://github.com/duoyang666/ai_novel) | 397 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/209155962?s=40&v=4" width="20" height="20" alt=""> [strands-agents](https://github.com/strands-agents) / [samples](https://github.com/strands-agents/samples) | 385 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/16997807?s=40&v=4" width="20" height="20" alt=""> [FranciscoMoretti](https://github.com/FranciscoMoretti) / [sparka](https://github.com/FranciscoMoretti/sparka) | 380 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 373 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/137044109?s=40&v=4" width="20" height="20" alt=""> [ElectricCodeGuy](https://github.com/ElectricCodeGuy) / [SupabaseAuthWithSSR](https://github.com/ElectricCodeGuy/SupabaseAuthWithSSR) | 370 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169401942?s=40&v=4" width="20" height="20" alt=""> [LibreChat-AI](https://github.com/LibreChat-AI) / [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) | 339 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/15125613?s=40&v=4" width="20" height="20" alt=""> [souzatharsis](https://github.com/souzatharsis) / [tamingLLMs](https://github.com/souzatharsis/tamingLLMs) | 323 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8931462?s=40&v=4" width="20" height="20" alt=""> [aws-samples](https://github.com/aws-samples) / [aws-ai-ml-workshop-kr](https://github.com/aws-samples/aws-ai-ml-workshop-kr) | 295 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/25676773?s=40&v=4" width="20" height="20" alt=""> [weizxfree](https://github.com/weizxfree) / [KnowFlow](https://github.com/weizxfree/KnowFlow) | 285 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/88676955?s=40&v=4" width="20" height="20" alt=""> [zenml-io](https://github.com/zenml-io) / [zenml-projects](https://github.com/zenml-io/zenml-projects) | 276 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/136071305?s=40&v=4" width="20" height="20" alt=""> [wxai-space](https://github.com/wxai-space) / [LightAgent](https://github.com/wxai-space/LightAgent) | 275 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/90278288?s=40&v=4" width="20" height="20" alt=""> [Ozamatash](https://github.com/Ozamatash) / [deep-research-mcp](https://github.com/Ozamatash/deep-research-mcp) | 269 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 241 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/828269?s=40&v=4" width="20" height="20" alt=""> [guyernest](https://github.com/guyernest) / [advanced-rag](https://github.com/guyernest/advanced-rag) | 238 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/175069511?s=40&v=4" width="20" height="20" alt=""> [bklieger-groq](https://github.com/bklieger-groq) / [mathtutor-on-groq](https://github.com/bklieger-groq/mathtutor-on-groq) | 233 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/123981229?s=40&v=4" width="20" height="20" alt=""> [plastic-labs](https://github.com/plastic-labs) / [honcho](https://github.com/plastic-labs/honcho) | 224 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/118967077?s=40&v=4" width="20" height="20" alt=""> [OVINC-CN](https://github.com/OVINC-CN) / [OpenWebUI](https://github.com/OVINC-CN/OpenWebUI) | 202 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31960995?s=40&v=4" width="20" height="20" alt=""> [zhutoutoutousan](https://github.com/zhutoutoutousan) / [worldquant-miner](https://github.com/zhutoutoutousan/worldquant-miner) | 202 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/29215115?s=40&v=4" width="20" height="20" alt=""> [iceener](https://github.com/iceener) / [ai](https://github.com/iceener/ai) | 186 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/187584218?s=40&v=4" width="20" height="20" alt=""> [giselles-ai](https://github.com/giselles-ai) / [giselle](https://github.com/giselles-ai/giselle) | 181 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/174666116?s=40&v=4" width="20" height="20" alt=""> [ai-shifu](https://github.com/ai-shifu) / [ai-shifu](https://github.com/ai-shifu/ai-shifu) | 181 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/8931462?s=40&v=4" width="20" height="20" alt=""> [aws-samples](https://github.com/aws-samples) / [sample-serverless-mcp-servers](https://github.com/aws-samples/sample-serverless-mcp-servers) | 175 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/202488052?s=40&v=4" width="20" height="20" alt=""> [celerforge](https://github.com/celerforge) / [freenote](https://github.com/celerforge/freenote) | 171 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/104478511?s=40&v=4" width="20" height="20" alt=""> [babelcloud](https://github.com/babelcloud) / [LLM-RGB](https://github.com/babelcloud/LLM-RGB) | 164 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 163 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 163 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/1009716?s=40&v=4" width="20" height="20" alt=""> [codecentric](https://github.com/codecentric) / [c4-genai-suite](https://github.com/codecentric/c4-genai-suite) | 152 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/196509932?s=40&v=4" width="20" height="20" alt=""> [XSpoonAi](https://github.com/XSpoonAi) / [spoon-core](https://github.com/XSpoonAi/spoon-core) | 150 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/139558948?s=40&v=4" width="20" height="20" alt=""> [chatchat-space](https://github.com/chatchat-space) / [LangGraph-Chatchat](https://github.com/chatchat-space/LangGraph-Chatchat) | 144 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> [langfuse](https://github.com/langfuse) / [langfuse-docs](https://github.com/langfuse/langfuse-docs) | 139 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/44976328?s=40&v=4" width="20" height="20" alt=""> [piyushgarg-dev](https://github.com/piyushgarg-dev) / [genai-cohort](https://github.com/piyushgarg-dev/genai-cohort) | 135 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/105285801?s=40&v=4" width="20" height="20" alt=""> [i-dot-ai](https://github.com/i-dot-ai) / [redbox](https://github.com/i-dot-ai/redbox) | 132 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/90423581?s=40&v=4" width="20" height="20" alt=""> [bmd1905](https://github.com/bmd1905) / [ChatOpsLLM](https://github.com/bmd1905/ChatOpsLLM) | 127 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/202074444?s=40&v=4" width="20" height="20" alt=""> [Fintech-Dreamer](https://github.com/Fintech-Dreamer) / [FinSynth](https://github.com/Fintech-Dreamer/FinSynth) | 121 |
|
||||
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/1566555?s=40&v=4" width="20" height="20" alt=""> [kenshiro-o](https://github.com/kenshiro-o) / [nagato-ai](https://github.com/kenshiro-o/nagato-ai) | 119 |
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
|
||||
@@ -17,3 +17,12 @@
|
||||
### Environment Variables
|
||||
|
||||
- Environment variables should be imported from the `env.mjs/ts` file of the respective package and not from `process.env.*` to ensure validation and typing.
|
||||
|
||||
## Redis Invocations
|
||||
|
||||
- Highlight usage of `redis.call` invocations. Those may have suboptimal redis cluster routing and will raise errors. Instead, use the native call patterns.
|
||||
Example: `await redis?.call("SET", key, "1", "NX", "EX", TTLSeconds);` should use `await redis?.set(key, "1", "EX", TTLSeconds, "NX");` instead.
|
||||
|
||||
## Langfuse Cloud
|
||||
|
||||
- When attempting to confirm if the current environment is Langfuse Cloud in the frontend, use the `useLangfuseCloudRegion` hook and never environment variables directly.
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-17}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
- 6379:6379
|
||||
|
||||
postgres:
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-17}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
start_period: 1s
|
||||
|
||||
postgres:
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-17}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
|
||||
# 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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-0:/bitnami/redis/data
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-1:/bitnami/redis/data
|
||||
@@ -75,7 +75,7 @@ services:
|
||||
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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-2:/bitnami/redis/data
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-3:/bitnami/redis/data
|
||||
@@ -95,7 +95,7 @@ services:
|
||||
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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-4:/bitnami/redis/data
|
||||
@@ -105,7 +105,7 @@ services:
|
||||
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
|
||||
image: docker.io/bitnamilegacy/redis-cluster:8.0
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis-cluster_data-5:/bitnami/redis/data
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
- 127.0.0.1:6379:6379
|
||||
|
||||
postgres:
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-17}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
|
||||
image: docker.io/postgres:${POSTGRES_VERSION:-17}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public/integrations/blob-storage
|
||||
endpoints:
|
||||
getBlobStorageIntegrations:
|
||||
docs: Get all blob storage integrations for the organization (requires organization-scoped API key)
|
||||
method: GET
|
||||
path: ""
|
||||
response: BlobStorageIntegrationsResponse
|
||||
|
||||
upsertBlobStorageIntegration:
|
||||
docs: Create or update a blob storage integration for a specific project (requires organization-scoped API key). The configuration is validated by performing a test upload to the bucket.
|
||||
method: PUT
|
||||
path: ""
|
||||
request: CreateBlobStorageIntegrationRequest
|
||||
response: BlobStorageIntegrationResponse
|
||||
|
||||
deleteBlobStorageIntegration:
|
||||
docs: Delete a blob storage integration by ID (requires organization-scoped API key)
|
||||
method: DELETE
|
||||
path: "/{id}"
|
||||
path-parameters:
|
||||
id: string
|
||||
response: BlobStorageIntegrationDeletionResponse
|
||||
|
||||
types:
|
||||
BlobStorageIntegrationType:
|
||||
enum:
|
||||
- S3
|
||||
- S3_COMPATIBLE
|
||||
- AZURE_BLOB_STORAGE
|
||||
|
||||
BlobStorageIntegrationFileType:
|
||||
enum:
|
||||
- JSON
|
||||
- CSV
|
||||
- JSONL
|
||||
|
||||
BlobStorageExportMode:
|
||||
enum:
|
||||
- FULL_HISTORY
|
||||
- FROM_TODAY
|
||||
- FROM_CUSTOM_DATE
|
||||
|
||||
BlobStorageExportFrequency:
|
||||
enum:
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
|
||||
CreateBlobStorageIntegrationRequest:
|
||||
properties:
|
||||
projectId:
|
||||
type: string
|
||||
docs: ID of the project in which to configure the blob storage integration
|
||||
type: BlobStorageIntegrationType
|
||||
bucketName:
|
||||
type: string
|
||||
docs: Name of the storage bucket
|
||||
endpoint:
|
||||
type: optional<string>
|
||||
docs: Custom endpoint URL (required for S3_COMPATIBLE type)
|
||||
region:
|
||||
type: string
|
||||
docs: Storage region
|
||||
accessKeyId:
|
||||
type: optional<string>
|
||||
docs: Access key ID for authentication
|
||||
secretAccessKey:
|
||||
type: optional<string>
|
||||
docs: Secret access key for authentication (will be encrypted when stored)
|
||||
prefix:
|
||||
type: optional<string>
|
||||
docs: Path prefix for exported files (must end with forward slash if provided)
|
||||
exportFrequency: BlobStorageExportFrequency
|
||||
enabled:
|
||||
type: boolean
|
||||
docs: Whether the integration is active
|
||||
forcePathStyle:
|
||||
type: boolean
|
||||
docs: Use path-style URLs for S3 requests
|
||||
fileType: BlobStorageIntegrationFileType
|
||||
exportMode: BlobStorageExportMode
|
||||
exportStartDate:
|
||||
type: optional<datetime>
|
||||
docs: Custom start date for exports (required when exportMode is FROM_CUSTOM_DATE)
|
||||
|
||||
BlobStorageIntegrationResponse:
|
||||
properties:
|
||||
id: string
|
||||
projectId: string
|
||||
type: BlobStorageIntegrationType
|
||||
bucketName: string
|
||||
endpoint: optional<string>
|
||||
region: string
|
||||
accessKeyId: optional<string>
|
||||
prefix: string
|
||||
exportFrequency: BlobStorageExportFrequency
|
||||
enabled: boolean
|
||||
forcePathStyle: boolean
|
||||
fileType: BlobStorageIntegrationFileType
|
||||
exportMode: BlobStorageExportMode
|
||||
exportStartDate: optional<datetime>
|
||||
nextSyncAt: optional<datetime>
|
||||
lastSyncAt: optional<datetime>
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
BlobStorageIntegrationsResponse:
|
||||
properties:
|
||||
data: list<BlobStorageIntegrationResponse>
|
||||
|
||||
BlobStorageIntegrationDeletionResponse:
|
||||
properties:
|
||||
message: string
|
||||
@@ -8,8 +8,9 @@ service:
|
||||
endpoints:
|
||||
batch:
|
||||
docs: |
|
||||
Batched ingestion for Langfuse Tracing.
|
||||
If you want to use tracing via the API, such as to build your own Langfuse client implementation, this is the only API route you need to implement.
|
||||
**Legacy endpoint for batch ingestion for Langfuse Observability.**
|
||||
|
||||
-> Please use the OpenTelemetry endpoint (`/api/public/otel`). Learn more: https://langfuse.com/integrations/native/opentelemetry
|
||||
|
||||
Within each batch, there can be multiple events.
|
||||
Each event has a type, an id, a timestamp, metadata and a body.
|
||||
|
||||
@@ -35,6 +35,18 @@ service:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a score config
|
||||
response: commons.ScoreConfig
|
||||
|
||||
update:
|
||||
docs: Update a score config
|
||||
method: PATCH
|
||||
path: /score-configs/{configId}
|
||||
path-parameters:
|
||||
configId:
|
||||
type: string
|
||||
docs: The unique langfuse identifier of a score config
|
||||
request: UpdateScoreConfigRequest
|
||||
response: commons.ScoreConfig
|
||||
|
||||
types:
|
||||
ScoreConfigs:
|
||||
properties:
|
||||
@@ -56,3 +68,23 @@ types:
|
||||
description:
|
||||
type: optional<string>
|
||||
docs: Description is shown across the Langfuse UI and can be used to e.g. explain the config categories in detail, why a numeric range was set, or provide additional context on config name or usage
|
||||
UpdateScoreConfigRequest:
|
||||
properties:
|
||||
isArchived:
|
||||
type: optional<boolean>
|
||||
docs: The status of the score config showing if it is archived or not
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the score config
|
||||
categories:
|
||||
type: optional<list<commons.ConfigCategory>>
|
||||
docs: Configure custom categories for categorical scores. Pass a list of objects with `label` and `value` properties. Categories are autogenerated for boolean configs and cannot be passed
|
||||
minValue:
|
||||
type: optional<double>
|
||||
docs: Configure a minimum value for numerical scores. If not set, the minimum value defaults to -∞
|
||||
maxValue:
|
||||
type: optional<double>
|
||||
docs: Configure a maximum value for numerical scores. If not set, the maximum value defaults to +∞
|
||||
description:
|
||||
type: optional<string>
|
||||
docs: Description is shown across the Langfuse UI and can be used to e.g. explain the config categories in detail, why a numeric range was set, or provide additional context on config name or usage
|
||||
|
||||
@@ -50,6 +50,9 @@ service:
|
||||
configId:
|
||||
type: optional<string>
|
||||
docs: Retrieve only scores with a specific configId.
|
||||
sessionId:
|
||||
type: optional<string>
|
||||
docs: Retrieve only scores with a specific sessionId.
|
||||
queueId:
|
||||
type: optional<string>
|
||||
docs: Retrieve only scores with a specific annotation queueId.
|
||||
|
||||
@@ -31,7 +31,7 @@ groups:
|
||||
# client-class-name: LangfuseClient
|
||||
|
||||
- name: fernapi/fern-typescript-node-sdk
|
||||
version: 2.6.1
|
||||
version: 2.12.3
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../generated/typescript
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"organization": "langfuse",
|
||||
"version": "0.56.0"
|
||||
}
|
||||
"version": "0.77.5"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.108.0",
|
||||
"version": "3.114.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -40,7 +40,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"release-it": "^19.0.4",
|
||||
"turbo": "^2.5.6"
|
||||
"turbo": "^2.5.8"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
|
||||
@@ -2,10 +2,15 @@ const { resolve } = require("node:path");
|
||||
|
||||
const project = resolve(process.cwd(), "tsconfig.json");
|
||||
|
||||
// Handle eslint-config-turbo's default export
|
||||
const turboConfig = require("eslint-config-turbo");
|
||||
const turboConfigToUse = turboConfig.default || turboConfig;
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["eslint:recommended", "prettier", "eslint-config-turbo"],
|
||||
plugins: ["only-warn"],
|
||||
// extends: ["eslint:recommended", "prettier"require().default],
|
||||
extends: ["eslint:recommended", "prettier"],
|
||||
plugins: ["only-warn", "turbo"],
|
||||
globals: {
|
||||
React: true,
|
||||
JSX: true,
|
||||
@@ -30,6 +35,7 @@ module.exports = {
|
||||
rules: {
|
||||
"no-redeclare": "off",
|
||||
"import/order": "off",
|
||||
...(turboConfigToUse.rules || {}),
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
@@ -51,5 +57,6 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
...(turboConfigToUse.overrides || []),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ const { resolve } = require("node:path");
|
||||
|
||||
const project = resolve(process.cwd(), "tsconfig.json");
|
||||
|
||||
const turboConfig = require("eslint-config-turbo");
|
||||
const turboConfigToUse = turboConfig.default || turboConfig;
|
||||
|
||||
/*
|
||||
* This is a custom ESLint configuration for use with
|
||||
* Next.js apps.
|
||||
@@ -16,7 +19,6 @@ module.exports = {
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/strict-type-checked",
|
||||
"eslint-config-turbo",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
@@ -47,6 +49,7 @@ module.exports = {
|
||||
ignorePatterns: ["node_modules/", "dist/"],
|
||||
// add rules configurations here
|
||||
rules: {
|
||||
...(turboConfigToUse.rules || {}),
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^2.5.6",
|
||||
"eslint-config-turbo": "^2.5.8",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"@azure/storage-blob": "^12.26.0",
|
||||
"@clickhouse/client": "^1.12.1",
|
||||
"@google-cloud/storage": "^7.17.0",
|
||||
"@langchain/anthropic": "^0.3.22",
|
||||
"@langchain/anthropic": "^0.3.29",
|
||||
"@langchain/aws": "^0.1.11",
|
||||
"@langchain/core": "^0.3.58",
|
||||
"@langchain/google-genai": "^0.2.12",
|
||||
@@ -73,8 +73,8 @@
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@react-email/components": "^0.5.1",
|
||||
"@react-email/render": "^1.2.1",
|
||||
"@slack/oauth": "^3.0.3",
|
||||
"@slack/web-api": "^7.9.3",
|
||||
"@slack/oauth": "^3.0.4",
|
||||
"@slack/web-api": "^7.10.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
@@ -86,7 +86,7 @@
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.3.28",
|
||||
"langchain": "^0.3.30",
|
||||
"langfuse-langchain": "3.38.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lossless-json": "^4.1.1",
|
||||
@@ -113,7 +113,6 @@
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"kysely-codegen": "^0.16.8",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"prisma": "^6.10.1",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
|
||||
@@ -641,6 +641,7 @@ export type Organization = {
|
||||
updated_at: Generated<Timestamp>;
|
||||
cloud_config: unknown | null;
|
||||
metadata: unknown | null;
|
||||
ai_features_enabled: Generated<boolean>;
|
||||
};
|
||||
export type OrganizationMembership = {
|
||||
id: string;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "organizations" ADD COLUMN "ai_features_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -110,6 +110,7 @@ model Organization {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see @/src/features/organizations/utils/cloudConfigSchema
|
||||
metadata Json?
|
||||
aiFeaturesEnabled Boolean @default(false) @map("ai_features_enabled")
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projects Project[]
|
||||
MembershipInvitation MembershipInvitation[]
|
||||
@@ -853,6 +854,8 @@ model EvalTemplate {
|
||||
@@map("eval_templates")
|
||||
}
|
||||
|
||||
// We currently assume in the evalRouter that _all_ job_executions are for EVAL job_configs.
|
||||
// If we ever extend this, we need to adjust the filter condition there. ref.: fetchJobExecutionsByStatus.
|
||||
enum JobType {
|
||||
EVAL
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
JobExecutionStatus,
|
||||
PrismaClient,
|
||||
type Project,
|
||||
ScoreConfigCategoryDomain,
|
||||
ScoreDataType,
|
||||
} from "../../src/index";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
|
||||
@@ -29,11 +30,6 @@ import {
|
||||
generateEvalTraceId,
|
||||
} from "./utils/seed-helpers";
|
||||
|
||||
type ConfigCategory = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const options = {
|
||||
environment: { type: "string" },
|
||||
} as const;
|
||||
@@ -110,6 +106,9 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// Realistic support chat scenario
|
||||
await createSupportChatSession(project1);
|
||||
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
@@ -533,23 +532,13 @@ export async function createDatasets(
|
||||
const datasetItem = await prisma.datasetItem.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: generateDatasetItemId(
|
||||
datasetName,
|
||||
index,
|
||||
projectId,
|
||||
SEED_DATASETS.indexOf(data) || 0,
|
||||
),
|
||||
id: generateDatasetItemId(datasetName, index, projectId),
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
id: generateDatasetItemId(
|
||||
datasetName,
|
||||
index,
|
||||
projectId,
|
||||
SEED_DATASETS.indexOf(data) || 0,
|
||||
),
|
||||
id: generateDatasetItemId(datasetName, index, projectId),
|
||||
datasetId: dataset.id,
|
||||
sourceTraceId: sourceTraceId ?? null,
|
||||
sourceObservationId: null,
|
||||
@@ -754,7 +743,7 @@ async function generateConfigsForProject(projects: Project[]) {
|
||||
name: string;
|
||||
id: string;
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
categories: ScoreConfigCategoryDomain[] | null;
|
||||
}[]
|
||||
> = new Map();
|
||||
|
||||
@@ -781,12 +770,30 @@ async function createTraceSessions(project1: Project, project2: Project) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createSupportChatSession(project: Project) {
|
||||
const sessionId = "support-chat-session";
|
||||
await prisma.traceSession.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: sessionId,
|
||||
projectId: project.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
projectId: project.id,
|
||||
environment: "default",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function generateConfigs(project: Project) {
|
||||
const configNameAndId: {
|
||||
name: string;
|
||||
id: string;
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
categories: ScoreConfigCategoryDomain[] | null;
|
||||
}[] = [];
|
||||
|
||||
const configs = [
|
||||
@@ -863,7 +870,7 @@ async function generateQueuesForProject(
|
||||
name: string;
|
||||
id: string;
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
categories: ScoreConfigCategoryDomain[] | null;
|
||||
}[]
|
||||
>,
|
||||
) {
|
||||
@@ -887,7 +894,7 @@ async function generateQueues(
|
||||
name: string;
|
||||
id: string;
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
categories: ScoreConfigCategoryDomain[] | null;
|
||||
}[],
|
||||
) {
|
||||
const queue = {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
TraceRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
createDatasetRunItem,
|
||||
createDatasetRunScore,
|
||||
} from "../../../src/server";
|
||||
|
||||
/**
|
||||
@@ -106,7 +107,6 @@ export class DataGenerator {
|
||||
input.datasetName,
|
||||
input.itemIndex,
|
||||
projectId,
|
||||
input.runNumber || 0,
|
||||
),
|
||||
dataset_item_input: input.item.input,
|
||||
dataset_item_expected_output: input.item.expectedOutput,
|
||||
@@ -216,6 +216,59 @@ export class DataGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates scores for dataset experiment scores with variable values.
|
||||
* Use for: Dataset experiments requiring detailed score tracking.
|
||||
*/
|
||||
generateDatasetScore(
|
||||
trace: TraceRecordInsertType,
|
||||
input: DatasetItemInput,
|
||||
projectId: string,
|
||||
scoreNames: string[],
|
||||
): ScoreRecordInsertType {
|
||||
const scoreId = `score-dataset-${input.datasetName}-${input.itemIndex}-${input.runNumber}-${projectId.slice(-8)}`;
|
||||
|
||||
return createTraceScore({
|
||||
id: scoreId,
|
||||
trace_id: trace.id,
|
||||
project_id: projectId,
|
||||
name: this.randomElement(scoreNames),
|
||||
value: Math.random() * 100,
|
||||
string_value: undefined,
|
||||
data_type: "NUMERIC",
|
||||
source: "API",
|
||||
environment: "langfuse-prompt-experiments",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates scores for dataset experiment traces with variable values.
|
||||
* Use for: Dataset experiments requiring detailed score tracking.
|
||||
*/
|
||||
generateDatasetRunScore(
|
||||
runId: string,
|
||||
input: {
|
||||
datasetName: string;
|
||||
runNumber: number;
|
||||
},
|
||||
projectId: string,
|
||||
scoreNames: string[],
|
||||
): ScoreRecordInsertType {
|
||||
const scoreId = `score-dataset-run-${input.datasetName}-${input.runNumber}-${projectId.slice(-8)}`;
|
||||
|
||||
return createDatasetRunScore({
|
||||
id: scoreId,
|
||||
project_id: projectId,
|
||||
dataset_run_id: runId,
|
||||
name: this.randomElement(scoreNames),
|
||||
value: Math.random() * 100,
|
||||
string_value: undefined,
|
||||
data_type: "NUMERIC",
|
||||
source: "API",
|
||||
environment: "langfuse-prompt-experiments",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates large-scale synthetic traces for performance testing.
|
||||
* Use for: Load testing, dashboard demos, realistic usage simulation.
|
||||
@@ -899,6 +952,310 @@ export class DataGenerator {
|
||||
: JSON.stringify(this.fileContent.chatMlJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates realistic support chat session data with conversational flow.
|
||||
* Use for: Demonstrating session-based conversations with tool calls and scoring.
|
||||
*/
|
||||
generateSupportChatSessionData(projectId: string): {
|
||||
traces: TraceRecordInsertType[];
|
||||
observations: ObservationRecordInsertType[];
|
||||
scores: ScoreRecordInsertType[];
|
||||
} {
|
||||
// Domain: Art museum membership support (non-tech)
|
||||
const dialogues = [
|
||||
{
|
||||
user: "Hi—my family membership at the City Art Museum was charged twice this month.",
|
||||
assistant:
|
||||
"Sorry about that. I can help. Could you share your membership ID and the last 4 digits of the card?",
|
||||
},
|
||||
{
|
||||
user: "Membership ID is CAM-48291, card ending 4417.",
|
||||
assistant: "Thanks. I'll check your billing records now.",
|
||||
tool: {
|
||||
name: "billing.lookup",
|
||||
input: { membershipId: "CAM-48291", last4: "4417" },
|
||||
output: {
|
||||
duplicateCharge: true,
|
||||
amounts: [95.0, 95.0],
|
||||
date: "2025-08-28",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: "Okay.",
|
||||
assistant:
|
||||
"I found a duplicate charge on 2025-08-28 for $95. I can issue a refund to the original payment method. Shall I proceed?",
|
||||
},
|
||||
{
|
||||
user: "Yes, please issue the refund.",
|
||||
assistant: "Processing your refund now. One moment.",
|
||||
tool: {
|
||||
name: "billing.refund",
|
||||
input: { membershipId: "CAM-48291", amount: 95.0 },
|
||||
output: { status: "success", refundId: "RFND-20931" },
|
||||
},
|
||||
},
|
||||
{
|
||||
user: "Thank you!",
|
||||
assistant:
|
||||
"Refund RFND-20931 has been issued. You'll see it on your statement within 3–5 business days.",
|
||||
},
|
||||
{
|
||||
user: "No, that's all. Appreciate the quick help!",
|
||||
assistant: "Happy to help. Enjoy your next visit to the museum!",
|
||||
},
|
||||
// a couple more lightweight turns for scrolling realism
|
||||
{
|
||||
user: "Oh, and do members get early access to exhibitions?",
|
||||
assistant:
|
||||
"Yes—members get a 48-hour early booking window and a preview evening invite.",
|
||||
},
|
||||
{
|
||||
user: "Perfect.",
|
||||
assistant: "You're all set. Have a great day!",
|
||||
},
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const traces: TraceRecordInsertType[] = dialogues.map((d, index) => ({
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
timestamp: now + index * 1000,
|
||||
name: "SupportChatSession",
|
||||
user_id: null,
|
||||
metadata: { scenario: "support-chat" },
|
||||
release: null,
|
||||
version: null,
|
||||
project_id: projectId,
|
||||
environment: "default",
|
||||
public: false,
|
||||
bookmarked: false,
|
||||
tags: ["support", "chat", "session"],
|
||||
input: JSON.stringify(
|
||||
d.tool
|
||||
? {
|
||||
messages: [
|
||||
{ role: "user", content: d.user },
|
||||
{ role: "assistant", content: d.assistant },
|
||||
{
|
||||
role: "tool",
|
||||
name: d.tool.name,
|
||||
content: d.tool.output,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { messages: [{ role: "user", content: d.user }] },
|
||||
),
|
||||
output: JSON.stringify({ role: "assistant", content: d.assistant }),
|
||||
session_id: "support-chat-session",
|
||||
created_at: now + index * 1000,
|
||||
updated_at: now + index * 1000 + 500,
|
||||
event_ts: now + index * 1000,
|
||||
is_deleted: 0,
|
||||
}));
|
||||
|
||||
// Create one GENERATION observation per trace
|
||||
const observations: ObservationRecordInsertType[] = dialogues
|
||||
.map((d, index) => {
|
||||
const start = now + index * 1000 + 50;
|
||||
const end = start + 400 + Math.floor(Math.random() * 400);
|
||||
const inputTokens = 80 + Math.floor(Math.random() * 60);
|
||||
const outputTokens = 60 + Math.floor(Math.random() * 60);
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
const baseGen: ObservationRecordInsertType = {
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}-gen`,
|
||||
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
project_id: projectId,
|
||||
type: "GENERATION",
|
||||
parent_observation_id: null,
|
||||
environment: "default",
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
name: "llm-generation",
|
||||
metadata: {},
|
||||
level: "DEFAULT",
|
||||
status_message: null,
|
||||
version: null,
|
||||
input: JSON.stringify({
|
||||
messages: [
|
||||
{ role: "user", content: d.user },
|
||||
d.tool
|
||||
? {
|
||||
role: "tool",
|
||||
name: d.tool.name,
|
||||
content: d.tool.output,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean),
|
||||
}),
|
||||
output: JSON.stringify({ role: "assistant", content: d.assistant }),
|
||||
provided_model_name: "gpt-4o",
|
||||
internal_model_id: null,
|
||||
model_parameters: JSON.stringify({ temperature: 0.2 }),
|
||||
provided_usage_details: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
total: totalTokens,
|
||||
},
|
||||
usage_details: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
total: totalTokens,
|
||||
},
|
||||
provided_cost_details: {
|
||||
input: Math.round(inputTokens * 2) / 1_000_000,
|
||||
output: Math.round(outputTokens * 3) / 1_000_000,
|
||||
total: Math.round(totalTokens * 5) / 1_000_000,
|
||||
},
|
||||
cost_details: {
|
||||
input: Math.round(inputTokens * 2) / 1_000_000,
|
||||
output: Math.round(outputTokens * 3) / 1_000_000,
|
||||
total: Math.round(totalTokens * 5) / 1_000_000,
|
||||
},
|
||||
total_cost: Math.round(totalTokens * 5) / 1_000_000,
|
||||
completion_start_time: start + 120,
|
||||
prompt_id: null,
|
||||
prompt_name: null,
|
||||
prompt_version: null,
|
||||
created_at: start,
|
||||
updated_at: end,
|
||||
event_ts: start,
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
if (!d.tool) return [baseGen];
|
||||
|
||||
const toolObs: ObservationRecordInsertType = {
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}-tool`,
|
||||
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
project_id: projectId,
|
||||
type: "TOOL",
|
||||
parent_observation_id: null,
|
||||
environment: "default",
|
||||
start_time: start - 40,
|
||||
end_time: start - 5,
|
||||
name: d.tool.name,
|
||||
metadata: {},
|
||||
level: "DEFAULT",
|
||||
status_message: null,
|
||||
version: null,
|
||||
input: JSON.stringify(d.tool.input),
|
||||
output: JSON.stringify(d.tool.output),
|
||||
provided_model_name: null,
|
||||
internal_model_id: null,
|
||||
model_parameters: null,
|
||||
provided_usage_details: {},
|
||||
usage_details: {},
|
||||
provided_cost_details: {},
|
||||
cost_details: {},
|
||||
total_cost: null,
|
||||
completion_start_time: null,
|
||||
prompt_id: null,
|
||||
prompt_name: null,
|
||||
prompt_version: null,
|
||||
created_at: start - 40,
|
||||
updated_at: start - 5,
|
||||
event_ts: start - 40,
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
return [toolObs, baseGen];
|
||||
})
|
||||
.flat();
|
||||
|
||||
// Create a couple of scores per trace
|
||||
const scores: ScoreRecordInsertType[] = dialogues
|
||||
.map((_, index) => {
|
||||
const baseTs = now + index * 1000 + 600;
|
||||
const helpfulness: ScoreRecordInsertType = {
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}-score-helpfulness`,
|
||||
project_id: projectId,
|
||||
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
session_id: null,
|
||||
dataset_run_id: null,
|
||||
observation_id: null,
|
||||
environment: "default",
|
||||
name: "helpfulness",
|
||||
value: 70 + Math.random() * 25,
|
||||
source: "API",
|
||||
comment: "Heuristic helpfulness score",
|
||||
metadata: {},
|
||||
author_user_id: null,
|
||||
config_id: null,
|
||||
data_type: "NUMERIC",
|
||||
string_value: null,
|
||||
queue_id: null,
|
||||
created_at: baseTs,
|
||||
updated_at: baseTs,
|
||||
timestamp: baseTs,
|
||||
event_ts: baseTs,
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
const safeVal = Math.random() > 0.1 ? 1 : 0;
|
||||
const safety: ScoreRecordInsertType = {
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}-score-safety`,
|
||||
project_id: projectId,
|
||||
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
session_id: null,
|
||||
dataset_run_id: null,
|
||||
observation_id: null,
|
||||
environment: "default",
|
||||
name: "safe",
|
||||
value: safeVal,
|
||||
source: "API",
|
||||
comment: "Content safety",
|
||||
metadata: {},
|
||||
author_user_id: null,
|
||||
config_id: null,
|
||||
data_type: "BOOLEAN",
|
||||
string_value: safeVal === 1 ? "true" : "false",
|
||||
queue_id: null,
|
||||
created_at: baseTs + 10,
|
||||
updated_at: baseTs + 10,
|
||||
timestamp: baseTs + 10,
|
||||
event_ts: baseTs + 10,
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
// Optional: resolution score on last turn
|
||||
const isFinal = index === dialogues.length - 1;
|
||||
const resolved: ScoreRecordInsertType | null = isFinal
|
||||
? {
|
||||
id: `support-chat-${index}-${projectId.slice(-8)}-score-resolved`,
|
||||
project_id: projectId,
|
||||
trace_id: `support-chat-${index}-${projectId.slice(-8)}`,
|
||||
session_id: null,
|
||||
dataset_run_id: null,
|
||||
observation_id: null,
|
||||
environment: "default",
|
||||
name: "resolved",
|
||||
value: 1,
|
||||
source: "API",
|
||||
comment: "Conversation resolved",
|
||||
metadata: {},
|
||||
author_user_id: null,
|
||||
config_id: null,
|
||||
data_type: "BOOLEAN",
|
||||
string_value: "true",
|
||||
queue_id: null,
|
||||
created_at: baseTs + 20,
|
||||
updated_at: baseTs + 20,
|
||||
timestamp: baseTs + 20,
|
||||
event_ts: baseTs + 20,
|
||||
is_deleted: 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
return [helpfulness, safety, resolved].filter(
|
||||
Boolean,
|
||||
) as ScoreRecordInsertType[];
|
||||
})
|
||||
.flat();
|
||||
|
||||
return { traces, observations, scores };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates exactly one score per evaluation trace with prefixed IDs.
|
||||
* Use for: Evaluation traces that need score validation, evaluator testing.
|
||||
|
||||
@@ -11,9 +11,8 @@ export const generateDatasetItemId = (
|
||||
datasetName: string,
|
||||
itemIndex: number,
|
||||
projectId: string,
|
||||
runNumber: number,
|
||||
) => {
|
||||
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
|
||||
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-0`;
|
||||
};
|
||||
|
||||
export const generateDatasetRunTraceId = (
|
||||
|
||||
@@ -7,11 +7,19 @@ import {
|
||||
DatasetRunItemRecordInsertType,
|
||||
logger,
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
TraceRecordInsertType,
|
||||
} from "../../../src/server";
|
||||
import path from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const DATASET_SCORE_NAMES = ["score-1", "score-2", "score-3"];
|
||||
const DATASET_RUN_SCORE_NAMES = [
|
||||
"dataset-run-score-1",
|
||||
"dataset-run-score-2",
|
||||
"dataset-run-score-3",
|
||||
];
|
||||
|
||||
/**
|
||||
* Orchestrates seeding operations across ClickHouse and PostgreSQL.
|
||||
*
|
||||
@@ -98,6 +106,7 @@ export class SeederOrchestrator {
|
||||
const traces: TraceRecordInsertType[] = [];
|
||||
const observations: ObservationRecordInsertType[] = [];
|
||||
const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
|
||||
const scores: ScoreRecordInsertType[] = [];
|
||||
|
||||
for (const seedDataset of SEED_DATASETS) {
|
||||
for (const [itemIndex, datasetItem] of seedDataset.items.entries()) {
|
||||
@@ -136,16 +145,43 @@ export class SeederOrchestrator {
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Generate score data
|
||||
const score = this.dataGenerator.generateDatasetScore(
|
||||
trace,
|
||||
{
|
||||
datasetName: seedDataset.name,
|
||||
itemIndex,
|
||||
item: datasetItem,
|
||||
runNumber,
|
||||
},
|
||||
projectId,
|
||||
DATASET_SCORE_NAMES,
|
||||
);
|
||||
|
||||
traces.push(trace);
|
||||
observations.push(observation);
|
||||
datasetRunItems.push(datasetRunItem);
|
||||
scores.push(score);
|
||||
}
|
||||
|
||||
// create dataset run level scores
|
||||
const datasetRunScore = this.dataGenerator.generateDatasetRunScore(
|
||||
`${seedDataset.name}-${projectId.slice(-8)}`,
|
||||
{
|
||||
datasetName: seedDataset.name,
|
||||
runNumber,
|
||||
},
|
||||
projectId,
|
||||
DATASET_RUN_SCORE_NAMES,
|
||||
);
|
||||
scores.push(datasetRunScore);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.queryBuilder.executeTracesInsert(traces);
|
||||
await this.queryBuilder.executeObservationsInsert(observations);
|
||||
await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
|
||||
await this.queryBuilder.executeScoresInsert(scores);
|
||||
} catch (error) {
|
||||
logger.error(`✗ Insert failed:`, error);
|
||||
throw error;
|
||||
@@ -285,6 +321,9 @@ export class SeederOrchestrator {
|
||||
// Create synthetic data
|
||||
await this.createSyntheticData(projectIds, opts);
|
||||
|
||||
// Create traces for a realistic chat session
|
||||
await this.createSupportChatSessionTraces(projectIds);
|
||||
|
||||
// Log completion statistics (commented out to reduce terminal noise)
|
||||
await this.logStatistics();
|
||||
|
||||
@@ -342,4 +381,27 @@ export class SeederOrchestrator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createSupportChatSessionTraces(projectIds: string[]): Promise<void> {
|
||||
logger.info(
|
||||
`Creating support chat session data for ${projectIds.length} projects.`,
|
||||
);
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
logger.info(`Processing support chat session for project ${projectId}`);
|
||||
|
||||
// Generate data using the data generator
|
||||
const { traces, observations, scores } =
|
||||
this.dataGenerator.generateSupportChatSessionData(projectId);
|
||||
|
||||
try {
|
||||
await this.queryBuilder.executeTracesInsert(traces);
|
||||
await this.queryBuilder.executeObservationsInsert(observations);
|
||||
await this.queryBuilder.executeScoresInsert(scores);
|
||||
} catch (error) {
|
||||
logger.error(`✗ Support chat session insert failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,14 @@ export const DatasetRunItemSchema = z.object({
|
||||
datasetItemMetadata: MetadataDomain,
|
||||
});
|
||||
|
||||
export type DatasetRunItemDomain = z.infer<typeof DatasetRunItemSchema>;
|
||||
// Conditional type for dataset run item domain with optional IO
|
||||
export type DatasetRunItemDomain<WithIO extends boolean = true> =
|
||||
WithIO extends true
|
||||
? z.infer<typeof DatasetRunItemSchema>
|
||||
: Omit<
|
||||
z.infer<typeof DatasetRunItemSchema>,
|
||||
| "datasetRunMetadata"
|
||||
| "datasetItemInput"
|
||||
| "datasetItemExpectedOutput"
|
||||
| "datasetItemMetadata"
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isPresent } from "../utils/typeChecks";
|
||||
|
||||
// Category type, used for categorical and boolean configs
|
||||
export const ScoreConfigCategory = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
// Numeric config fields
|
||||
export const NumericConfigFields = z.object({
|
||||
maxValue: z.number().nullish(),
|
||||
minValue: z.number().nullish(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
categories: z.undefined().nullish(),
|
||||
});
|
||||
|
||||
// Boolean config fields
|
||||
export const BooleanConfigFields = z.object({
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
maxValue: z.number().nullish(),
|
||||
minValue: z.number().nullish(),
|
||||
categories: z
|
||||
.array(ScoreConfigCategory)
|
||||
.length(2, "Boolean data type must have exactly 2 categories.")
|
||||
.refine((categories) => {
|
||||
const expectedCategories = [
|
||||
{ label: "True", value: 1 },
|
||||
{ label: "False", value: 0 },
|
||||
];
|
||||
return categories.every(
|
||||
(category, index) =>
|
||||
category.label === expectedCategories[index].label &&
|
||||
category.value === expectedCategories[index].value,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
// Category config fields and types
|
||||
export const validateCategories = (
|
||||
categories: z.infer<typeof ScoreConfigCategory>[],
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
const uniqueNames = new Set<string>();
|
||||
const uniqueValues = new Set<number>();
|
||||
|
||||
for (const category of categories) {
|
||||
if (uniqueNames.has(category.label)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate category label: ${category.label}, category labels must be unique`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
uniqueNames.add(category.label);
|
||||
|
||||
if (uniqueValues.has(category.value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate category value: ${category.value}, category values must be unique`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
uniqueValues.add(category.value);
|
||||
}
|
||||
};
|
||||
|
||||
export const CategoricalConfigFields = z.object({
|
||||
maxValue: z.undefined().nullish(),
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
categories: z.array(ScoreConfigCategory).superRefine(validateCategories),
|
||||
});
|
||||
|
||||
const ScoreConfigBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(35),
|
||||
isArchived: z.boolean(),
|
||||
description: z.string().nullish(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
export const validateNumericRangeFields = (
|
||||
data: Pick<ScoreConfigDomain, "maxValue" | "minValue" | "dataType">,
|
||||
ctx: z.RefinementCtx,
|
||||
): void | Promise<void> => {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
if (
|
||||
isPresent(data.maxValue) &&
|
||||
isPresent(data.minValue) &&
|
||||
data.maxValue <= data.minValue
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Maximum value must be greater than Minimum value",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ScoreConfigSchema = z
|
||||
.discriminatedUnion("dataType", [
|
||||
z.object({
|
||||
...ScoreConfigBase.shape,
|
||||
...NumericConfigFields.shape,
|
||||
}),
|
||||
z.object({
|
||||
...ScoreConfigBase.shape,
|
||||
...CategoricalConfigFields.shape,
|
||||
}),
|
||||
z.object({
|
||||
...ScoreConfigBase.shape,
|
||||
...BooleanConfigFields.shape,
|
||||
}),
|
||||
])
|
||||
.superRefine(validateNumericRangeFields);
|
||||
|
||||
export type ScoreConfigDomain = z.infer<typeof ScoreConfigSchema>;
|
||||
export type ScoreConfigCategoryDomain = z.infer<typeof ScoreConfigCategory>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ScoreDataType } from "@prisma/client";
|
||||
import z from "zod/v4";
|
||||
import { MetadataDomain } from "./traces";
|
||||
import { ScoreDataType } from "@prisma/client";
|
||||
|
||||
export const ScoreSource = {
|
||||
ANNOTATION: "ANNOTATION",
|
||||
|
||||
@@ -38,6 +38,7 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300), // 5 minutes
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_READ_ONLY_URL: z.string().url().optional(),
|
||||
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
@@ -156,9 +157,6 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_WHITELISTED_AMT_TABLES: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -197,6 +195,12 @@ const EnvSchema = z.object({
|
||||
return new Map<string, number>();
|
||||
}
|
||||
}),
|
||||
LANGFUSE_WEBHOOK_WHITELISTED_IPS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_STATE_SECRET: z.string().optional(),
|
||||
@@ -227,6 +231,14 @@ const EnvSchema = z.object({
|
||||
.int()
|
||||
.positive()
|
||||
.default(120_000), // 2 minutes
|
||||
|
||||
LANGFUSE_AWS_BEDROCK_REGION: z.string().optional(),
|
||||
|
||||
// Langfuse AI Features
|
||||
LANGFUSE_AI_FEATURES_PUBLIC_KEY: z.string().optional(),
|
||||
LANGFUSE_AI_FEATURES_SECRET_KEY: z.string().optional(),
|
||||
LANGFUSE_AI_FEATURES_HOST: z.string().optional(),
|
||||
LANGFUSE_AI_FEATURES_PROJECT_ID: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./validation";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod/v4";
|
||||
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
|
||||
import {
|
||||
ScoreConfigDomain,
|
||||
ScoreConfigSchema,
|
||||
} from "../../domain/score-configs";
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scoreConfigs
|
||||
* @returns list of validated score configs
|
||||
*/
|
||||
export const filterAndValidateDbScoreConfigList = (
|
||||
scoreConfigs: ScoreConfigDbType[],
|
||||
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
|
||||
): ScoreConfigDomain[] =>
|
||||
scoreConfigs.reduce((acc, ts) => {
|
||||
const result = ScoreConfigSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as ScoreConfigDomain[]);
|
||||
|
||||
/**
|
||||
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
|
||||
* The score is expected to pass the validation. If a score fails validation, an error will be thrown.
|
||||
* @param scoreConfig
|
||||
* @returns validated score config
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScoreConfig = (
|
||||
scoreConfig: ScoreConfigDbType,
|
||||
): ScoreConfigDomain => ScoreConfigSchema.parse(scoreConfig);
|
||||
|
||||
/**
|
||||
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
|
||||
* This function will NOT throw an error by default. The score is expected to pass the validation.
|
||||
* @param scoreConfig
|
||||
* @returns score config validation object:
|
||||
* - success: true if the score config passes validation
|
||||
* - data: the validated score config if success is true
|
||||
* - error: the error object if success is false
|
||||
*/
|
||||
export const validateDbScoreConfigSafe = (scoreConfig: ScoreConfigDbType) =>
|
||||
ScoreConfigSchema.safeParse(scoreConfig);
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./interfaces";
|
||||
export * from "./scoreConfigTypes";
|
||||
|
||||
@@ -8,7 +8,9 @@ export const GetScoreQueryV2 = GetScoreQuery;
|
||||
export const GetScoreResponseV2 = APIScoreSchemaV2;
|
||||
|
||||
// GET /scores v2
|
||||
export const GetScoresQueryV2 = GetScoresQuery;
|
||||
export const GetScoresQueryV2 = GetScoresQuery.extend({
|
||||
sessionId: z.string().nullish(),
|
||||
});
|
||||
export const GetScoreResponseDataV2 = z.intersection(
|
||||
APIScoreSchemaV2,
|
||||
z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@ import z from "zod/v4";
|
||||
import { applyScoreValidation } from "../../../../utils/scores";
|
||||
import { PostScoreBodyFoundationSchema } from "../shared";
|
||||
import { isPresent } from "../../../../utils/typeChecks";
|
||||
import { Category as ConfigCategory } from "../../scoreConfigTypes";
|
||||
import { ScoreConfigCategory } from "../../../../domain/score-configs";
|
||||
|
||||
export const ScoreBodyWithoutConfig = applyScoreValidation(
|
||||
z.discriminatedUnion("dataType", [
|
||||
@@ -54,7 +54,7 @@ const ScorePropsAgainstConfigNumeric = z
|
||||
const ScorePropsAgainstConfigCategorical = z
|
||||
.object({
|
||||
value: z.string(),
|
||||
categories: z.array(ConfigCategory),
|
||||
categories: z.array(ScoreConfigCategory),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
|
||||
|
||||
import { isPresent } from "../../utils/typeChecks";
|
||||
import {
|
||||
jsonSchema,
|
||||
paginationMetaResponseZod,
|
||||
publicApiPaginationZod,
|
||||
} from "../../utils/zod";
|
||||
|
||||
/**
|
||||
* Types to use across codebase
|
||||
*/
|
||||
export type ConfigCategory = z.infer<typeof Category>;
|
||||
export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
|
||||
|
||||
const validateCategories = (
|
||||
categories: ConfigCategory[],
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
const uniqueNames = new Set<string>();
|
||||
const uniqueValues = new Set<number>();
|
||||
|
||||
for (const category of categories) {
|
||||
if (uniqueNames.has(category.label)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate category label: ${category.label}, category labels must be unique`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
uniqueNames.add(category.label);
|
||||
|
||||
if (uniqueValues.has(category.value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate category value: ${category.value}, category values must be unique`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
uniqueValues.add(category.value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Objects
|
||||
*/
|
||||
export const Category = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const Categories = z.array(Category);
|
||||
|
||||
const NumericScoreConfig = z.object({
|
||||
maxValue: z.number().nullish(),
|
||||
minValue: z.number().nullish(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
categories: z.undefined().nullish(),
|
||||
});
|
||||
|
||||
const CategoricalScoreConfig = z.object({
|
||||
maxValue: z.undefined().nullish(),
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
categories: jsonSchema.superRefine((categories, ctx) => {
|
||||
const parseResult = Categories.safeParse(categories);
|
||||
if (!parseResult.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Category must be an array of objects with label value pairs, where labels and values are unique.",
|
||||
} as z.core.$ZodIssueCustom);
|
||||
return;
|
||||
}
|
||||
|
||||
validateCategories(categories as ConfigCategory[], ctx);
|
||||
}),
|
||||
});
|
||||
|
||||
const BooleanScoreConfig = z.object({
|
||||
maxValue: z.undefined().nullish(),
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
categories: z
|
||||
.array(Category)
|
||||
.length(2, "Boolean data type must have exactly 2 categories.")
|
||||
.refine((categories) => {
|
||||
const expectedCategories = [
|
||||
{ label: "True", value: 1 },
|
||||
{ label: "False", value: 0 },
|
||||
];
|
||||
return categories.every(
|
||||
(category, index) =>
|
||||
category.label === expectedCategories[index].label &&
|
||||
category.value === expectedCategories[index].value,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
const ScoreConfigBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(35),
|
||||
isArchived: z.boolean(),
|
||||
description: z.string().nullish(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
const ScoreConfigPostBase = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const ValidatedScoreConfigSchema = z
|
||||
.union([
|
||||
ScoreConfigBase.merge(NumericScoreConfig),
|
||||
ScoreConfigBase.merge(
|
||||
z.object({
|
||||
maxValue: z.undefined().nullish(),
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
categories: Categories.superRefine(validateCategories),
|
||||
}),
|
||||
),
|
||||
ScoreConfigBase.merge(BooleanScoreConfig),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
if (
|
||||
isPresent(data.maxValue) &&
|
||||
isPresent(data.minValue) &&
|
||||
data.maxValue <= data.minValue
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Maximum value must be greater than Minimum value",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scoreConfigs
|
||||
* @returns list of validated score configs
|
||||
*/
|
||||
export const filterAndValidateDbScoreConfigList = (
|
||||
scoreConfigs: ScoreConfigDbType[],
|
||||
onParseError?: (error: z.ZodError) => void, // eslint-disable-line no-unused-vars
|
||||
): ValidatedScoreConfig[] =>
|
||||
scoreConfigs.reduce((acc, ts) => {
|
||||
const result = ValidatedScoreConfigSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as ValidatedScoreConfig[]);
|
||||
|
||||
/**
|
||||
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
|
||||
* The score is expected to pass the validation. If a score fails validation, an error will be thrown.
|
||||
* @param scoreConfig
|
||||
* @returns validated score config
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScoreConfig = (
|
||||
scoreConfig: ScoreConfigDbType,
|
||||
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
|
||||
|
||||
/**
|
||||
* Use this function when pulling a single score config from the database before using in the application to ensure type safety.
|
||||
* This function will NOT throw an error by default. The score is expected to pass the validation.
|
||||
* @param scoreConfig
|
||||
* @returns score config validation object:
|
||||
* - success: true if the score config passes validation
|
||||
* - data: the validated score config if success is true
|
||||
* - error: the error object if success is false
|
||||
*/
|
||||
export const validateDbScoreConfigSafe = (scoreConfig: ScoreConfigDbType) =>
|
||||
ValidatedScoreConfigSchema.safeParse(scoreConfig);
|
||||
|
||||
/**
|
||||
* Endpoints
|
||||
*/
|
||||
|
||||
// GET /score-configs/{configId}
|
||||
export const GetScoreConfigQuery = z.object({
|
||||
configId: z.string(),
|
||||
});
|
||||
|
||||
export const GetScoreConfigResponse = ValidatedScoreConfigSchema;
|
||||
|
||||
// POST /score-configs
|
||||
export const PostScoreConfigBody = z
|
||||
.discriminatedUnion("dataType", [
|
||||
ScoreConfigPostBase.merge(CategoricalScoreConfig),
|
||||
ScoreConfigPostBase.merge(NumericScoreConfig),
|
||||
ScoreConfigPostBase.merge(
|
||||
z.object({
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
categories: z.undefined().nullish(),
|
||||
}),
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
if (
|
||||
isPresent(data.maxValue) &&
|
||||
isPresent(data.minValue) &&
|
||||
data.maxValue <= data.minValue
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Maximum value must be greater than Minimum value",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const PostScoreConfigResponse = ValidatedScoreConfigSchema;
|
||||
|
||||
// GET /score-configs
|
||||
export const GetScoreConfigsQuery = z.object({
|
||||
...publicApiPaginationZod,
|
||||
});
|
||||
|
||||
export const GetScoreConfigsResponse = z.object({
|
||||
data: z.array(ValidatedScoreConfigSchema),
|
||||
meta: paginationMetaResponseZod,
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export * from "./interfaces/rate-limits";
|
||||
export * from "./tableDefinitions/typeHelpers";
|
||||
export * from "./domain/webhooks";
|
||||
export * from "./domain/dataset-run-items";
|
||||
export * from "./domain/score-configs";
|
||||
|
||||
// llm api
|
||||
export * from "./server/llm/types";
|
||||
@@ -37,6 +38,9 @@ export * from "./features/annotation/types";
|
||||
// scores
|
||||
export * from "./features/scores";
|
||||
|
||||
// score configs
|
||||
export * from "./features/scoreConfigs";
|
||||
|
||||
// comments
|
||||
export * from "./features/comments/types";
|
||||
|
||||
|
||||
@@ -13,7 +13,14 @@ export const CloudConfigSchema = z.object({
|
||||
customerId: z.string().optional(),
|
||||
activeSubscriptionId: z.string().optional(),
|
||||
activeProductId: z.string().optional(),
|
||||
activeUsageProductId: z.string().optional(),
|
||||
})
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
isLegacySubscription:
|
||||
data?.activeProductId !== undefined &&
|
||||
data?.activeUsageProductId === undefined,
|
||||
}))
|
||||
.optional(),
|
||||
|
||||
// custom rate limits for an organization
|
||||
|
||||
@@ -6,6 +6,8 @@ import { propagation, context } from "@opentelemetry/api";
|
||||
|
||||
export type ClickhouseClientType = ReturnType<typeof createClient>;
|
||||
|
||||
export type PreferredClickhouseService = "ReadWrite" | "ReadOnly";
|
||||
|
||||
/**
|
||||
* ClickHouseClientManager provides a singleton pattern for managing ClickHouse clients.
|
||||
* It creates and reuses clients based on their configuration to avoid creating
|
||||
@@ -35,22 +37,40 @@ export class ClickHouseClientManager {
|
||||
* @param opts Client parameters
|
||||
* @returns String hash key
|
||||
*/
|
||||
private generateClientSettingsKey(
|
||||
private generateClientSettings(
|
||||
opts: NodeClickHouseClientConfigOptions,
|
||||
): string {
|
||||
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
|
||||
): NodeClickHouseClientConfigOptions {
|
||||
const keyParams = {
|
||||
url: env.CLICKHOUSE_URL,
|
||||
url: this.getClickhouseUrl(preferredClickhouseService),
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: env.CLICKHOUSE_DB,
|
||||
http_headers: opts?.http_headers,
|
||||
http_headers: opts?.http_headers ?? {},
|
||||
settings: opts?.clickhouse_settings,
|
||||
request_timeout: opts?.request_timeout ?? 30000,
|
||||
...(opts.request_timeout
|
||||
? { request_timeout: opts.request_timeout }
|
||||
: {}),
|
||||
|
||||
// Include any other relevant config options
|
||||
};
|
||||
return JSON.stringify(keyParams);
|
||||
return keyParams;
|
||||
}
|
||||
|
||||
private generateClientSettingsKey(
|
||||
settings: NodeClickHouseClientConfigOptions,
|
||||
): string {
|
||||
return JSON.stringify(settings);
|
||||
}
|
||||
|
||||
private getClickhouseUrl = (
|
||||
preferredClickhouseService: PreferredClickhouseService,
|
||||
) => {
|
||||
return preferredClickhouseService === "ReadWrite"
|
||||
? env.CLICKHOUSE_URL
|
||||
: env.CLICKHOUSE_READ_ONLY_URL || env.CLICKHOUSE_URL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or create a client based on the provided parameters
|
||||
* @param opts Client configuration parameters
|
||||
@@ -58,13 +78,17 @@ export class ClickHouseClientManager {
|
||||
*/
|
||||
public getClient(
|
||||
opts: NodeClickHouseClientConfigOptions,
|
||||
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
|
||||
): ClickhouseClientType {
|
||||
const key = this.generateClientSettingsKey(opts);
|
||||
const settings = this.generateClientSettings(
|
||||
opts,
|
||||
preferredClickhouseService,
|
||||
);
|
||||
const key = this.generateClientSettingsKey(settings);
|
||||
if (!this.clientMap.has(key)) {
|
||||
const headers = opts?.http_headers ?? {};
|
||||
const activeSpan = getCurrentSpan();
|
||||
if (activeSpan) {
|
||||
propagation.inject(context.active(), headers);
|
||||
propagation.inject(context.active(), settings.http_headers);
|
||||
}
|
||||
|
||||
const cloudOptions: Record<string, unknown> = {};
|
||||
@@ -78,11 +102,7 @@ export class ClickHouseClientManager {
|
||||
|
||||
const client = createClient({
|
||||
...opts,
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: env.CLICKHOUSE_DB,
|
||||
http_headers: headers,
|
||||
...settings,
|
||||
keep_alive: {
|
||||
idle_socket_ttl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL,
|
||||
},
|
||||
@@ -99,9 +119,6 @@ export class ClickHouseClientManager {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(opts.request_timeout
|
||||
? { request_timeout: opts.request_timeout }
|
||||
: {}),
|
||||
});
|
||||
|
||||
this.clientMap.set(key, client);
|
||||
@@ -122,8 +139,14 @@ export class ClickHouseClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) => {
|
||||
return ClickHouseClientManager.getInstance().getClient(opts ?? {});
|
||||
export const clickhouseClient = (
|
||||
opts?: NodeClickHouseClientConfigOptions,
|
||||
preferredClickhouseService: PreferredClickhouseService = "ReadWrite",
|
||||
) => {
|
||||
return ClickHouseClientManager.getInstance().getClient(
|
||||
opts ?? {},
|
||||
preferredClickhouseService,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { type Model } from "../../db";
|
||||
import { env } from "../../env";
|
||||
import {
|
||||
InvalidRequestError,
|
||||
@@ -50,12 +49,6 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
|
||||
return s3StorageServiceClient;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export type TokenCountDelegate = (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined;
|
||||
|
||||
/**
|
||||
* Get the delay for the event based on the event type. Uses delay if set, 0 if current UTC timestamp is not between
|
||||
* 23:45 and 00:15, and env.LANGFUSE_INGESTION_QUEUE_DELAY_MS otherwise.
|
||||
@@ -115,6 +108,9 @@ export const processEventBatch = async (
|
||||
error?: string;
|
||||
}[];
|
||||
}> => {
|
||||
if (input.length === 0) {
|
||||
return { successes: [], errors: [] };
|
||||
}
|
||||
const { delay = null, source = "api", isLangfuseInternal = false } = options;
|
||||
|
||||
// add context of api call to the span
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
ScoreBodyWithoutConfig,
|
||||
ScoreConfigDomain,
|
||||
ScoreDomain,
|
||||
ScorePropsAgainstConfig,
|
||||
validateDbScoreConfigSafe,
|
||||
ValidatedScoreConfig,
|
||||
} from "../../../src";
|
||||
import { prisma, ScoreDataType } from "../../db";
|
||||
|
||||
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
|
||||
import { validateDbScoreConfigSafe } from "../../features/scoreConfigs/validation";
|
||||
import { ScoreEventType } from "./types";
|
||||
|
||||
type ValidateAndInflateScoreParams = {
|
||||
projectId: string;
|
||||
scoreId: string;
|
||||
body: any;
|
||||
body: ScoreEventType["body"];
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams,
|
||||
): Promise<ScoreDomain> {
|
||||
) {
|
||||
const { body, projectId, scoreId } = params;
|
||||
|
||||
if (body.configId) {
|
||||
@@ -40,16 +40,17 @@ export async function validateAndInflateScore(
|
||||
name: config.name,
|
||||
};
|
||||
|
||||
validateConfigAgainstBody(
|
||||
bodyWithConfigOverrides,
|
||||
config as ValidatedScoreConfig,
|
||||
);
|
||||
validateConfigAgainstBody({
|
||||
body: bodyWithConfigOverrides,
|
||||
config: config as ScoreConfigDomain,
|
||||
context: "INGESTION",
|
||||
});
|
||||
|
||||
return inflateScoreBody({
|
||||
projectId,
|
||||
scoreId,
|
||||
body: bodyWithConfigOverrides,
|
||||
config: config as ValidatedScoreConfig,
|
||||
config: config as ScoreConfigDomain,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ function inferDataType(value: string | number): ScoreDataType {
|
||||
}
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
config: ScoreConfigDomain,
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
@@ -84,12 +85,17 @@ function mapStringValueToNumericValue(
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
|
||||
): ScoreDomain {
|
||||
params: ValidateAndInflateScoreParams & { config?: ScoreConfigDomain },
|
||||
) {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
const relevantDataType = config?.dataType ?? body.dataType;
|
||||
const scoreProps = { source: "API", ...body, id: scoreId, projectId };
|
||||
const scoreProps = {
|
||||
...body,
|
||||
source: body.source ?? "API",
|
||||
id: scoreId,
|
||||
projectId,
|
||||
};
|
||||
|
||||
if (typeof body.value === "number") {
|
||||
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
|
||||
@@ -104,6 +110,7 @@ function inflateScoreBody(
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
stringValue: null,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
};
|
||||
}
|
||||
@@ -116,10 +123,43 @@ function inflateScoreBody(
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
config: ValidatedScoreConfig,
|
||||
): void {
|
||||
type ScoreBodyWithContext =
|
||||
| {
|
||||
body: ScoreEventType["body"];
|
||||
context: "INGESTION";
|
||||
}
|
||||
| {
|
||||
body: ScoreDomain;
|
||||
context: "ANNOTATION";
|
||||
};
|
||||
|
||||
function resolveScoreValueIngestion(
|
||||
body: ScoreEventType["body"],
|
||||
): string | number | null {
|
||||
return body.value;
|
||||
}
|
||||
|
||||
function resolveScoreValueAnnotation(
|
||||
body: ScoreDomain,
|
||||
): string | number | null {
|
||||
switch (body.dataType) {
|
||||
case ScoreDataType.NUMERIC:
|
||||
case ScoreDataType.BOOLEAN:
|
||||
return body.value;
|
||||
case ScoreDataType.CATEGORICAL:
|
||||
return body.stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateConfigAgainstBodyParams = {
|
||||
config: ScoreConfigDomain;
|
||||
} & ScoreBodyWithContext;
|
||||
|
||||
export function validateConfigAgainstBody({
|
||||
body,
|
||||
config,
|
||||
context,
|
||||
}: ValidateConfigAgainstBodyParams): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
@@ -141,20 +181,13 @@ function validateConfigAgainstBody(
|
||||
}
|
||||
|
||||
const relevantDataType = configDataType ?? body.dataType;
|
||||
|
||||
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: relevantDataType,
|
||||
});
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
const scoreValue =
|
||||
context === "INGESTION"
|
||||
? resolveScoreValueIngestion(body)
|
||||
: resolveScoreValueAnnotation(body);
|
||||
|
||||
const rangeValidation = ScorePropsAgainstConfig.safeParse({
|
||||
value: body.value,
|
||||
value: scoreValue,
|
||||
dataType: relevantDataType,
|
||||
...(maxValue !== null && maxValue !== undefined && { maxValue }),
|
||||
...(minValue !== null && minValue !== undefined && { minValue }),
|
||||
|
||||
@@ -25,6 +25,7 @@ import GCPServiceAccountKeySchema, {
|
||||
BedrockCredentialSchema,
|
||||
VertexAIConfigSchema,
|
||||
BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
BedrockCredential,
|
||||
} from "../../interfaces/customLLMProviderConfigSchemas";
|
||||
import { processEventBatch } from "../ingestion/processEventBatch";
|
||||
import { logger } from "../logger";
|
||||
@@ -32,10 +33,12 @@ import {
|
||||
ChatMessage,
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
isOpenAIReasoningModel,
|
||||
LLMAdapter,
|
||||
LLMJSONSchema,
|
||||
LLMToolDefinition,
|
||||
ModelParams,
|
||||
OpenAIModel,
|
||||
ToolCallResponse,
|
||||
ToolCallResponseSchema,
|
||||
TraceParams,
|
||||
@@ -46,8 +49,56 @@ import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const PROVIDERS_WITH_REQUIRED_USER_MESSAGE = [
|
||||
LLMAdapter.VertexAI,
|
||||
LLMAdapter.Anthropic,
|
||||
];
|
||||
|
||||
const resolveBedrockCredentials = (
|
||||
apiKey: string,
|
||||
context: LLMCompletionContext,
|
||||
): BedrockCredential | undefined => {
|
||||
// Default credentials can only be used in self-hosted deployments or when credentials are managed by langfuse
|
||||
const canUseDefaultCredentials =
|
||||
!isLangfuseCloud || context.credentials === "langfuse";
|
||||
return apiKey === BEDROCK_USE_DEFAULT_CREDENTIALS && canUseDefaultCredentials
|
||||
? undefined // undefined = use AWS SDK default credential provider chain
|
||||
: BedrockCredentialSchema.parse(JSON.parse(apiKey));
|
||||
};
|
||||
|
||||
const resolveBedrockRegion = (
|
||||
context: LLMCompletionContext,
|
||||
config?: Record<string, string> | null,
|
||||
): string | undefined => {
|
||||
if (context.credentials === "langfuse") {
|
||||
return env.LANGFUSE_AWS_BEDROCK_REGION ?? undefined;
|
||||
}
|
||||
const { region } = BedrockConfigSchema.parse(config);
|
||||
return region;
|
||||
};
|
||||
|
||||
const transformSystemMessageToUserMessage = (
|
||||
messages: ChatMessage[],
|
||||
): BaseMessage[] => {
|
||||
const safeContent =
|
||||
typeof messages[0].content === "string"
|
||||
? messages[0].content
|
||||
: JSON.stringify(messages[0].content);
|
||||
return [new HumanMessage(safeContent)];
|
||||
};
|
||||
|
||||
type ProcessTracedEvents = () => Promise<void>;
|
||||
|
||||
type LLMCompletionContext = {
|
||||
tracing: "langfuse";
|
||||
credentials: "user" | "langfuse";
|
||||
};
|
||||
|
||||
const DEFAULT_LLM_COMPLETION_CONTEXT: LLMCompletionContext = {
|
||||
tracing: "langfuse",
|
||||
credentials: "user",
|
||||
};
|
||||
|
||||
type LLMCompletionParams = {
|
||||
messages: ChatMessage[];
|
||||
modelParams: ModelParams;
|
||||
@@ -60,6 +111,7 @@ type LLMCompletionParams = {
|
||||
config?: Record<string, string> | null;
|
||||
traceParams?: TraceParams;
|
||||
throwOnError?: boolean; // default is true
|
||||
context?: LLMCompletionContext;
|
||||
};
|
||||
|
||||
type FetchLLMCompletionParams = LLMCompletionParams & {
|
||||
@@ -133,33 +185,57 @@ export async function fetchLLMCompletion(
|
||||
traceParams,
|
||||
extraHeaders,
|
||||
throwOnError = true,
|
||||
context = DEFAULT_LLM_COMPLETION_CONTEXT,
|
||||
} = params;
|
||||
|
||||
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
|
||||
let processTracedEvents: ProcessTracedEvents = () => Promise.resolve();
|
||||
|
||||
if (traceParams) {
|
||||
const handler = new CallbackHandler({
|
||||
_projectId: traceParams.projectId,
|
||||
_isLocalEventExportEnabled: true,
|
||||
environment: traceParams.environment,
|
||||
});
|
||||
finalCallbacks.push(handler);
|
||||
let handler: CallbackHandler;
|
||||
|
||||
processTracedEvents = async () => {
|
||||
try {
|
||||
const events = await handler.langfuse._exportLocalEvents(
|
||||
traceParams.projectId,
|
||||
);
|
||||
await processEventBatch(
|
||||
JSON.parse(JSON.stringify(events)), // stringify to emulate network event batch from network call
|
||||
traceParams.authCheck,
|
||||
{ isLangfuseInternal: true },
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error("Failed to process traced events", { error: e });
|
||||
}
|
||||
};
|
||||
if (
|
||||
context.credentials === "langfuse" &&
|
||||
env.LANGFUSE_AI_FEATURES_PUBLIC_KEY &&
|
||||
env.LANGFUSE_AI_FEATURES_SECRET_KEY &&
|
||||
env.LANGFUSE_AI_FEATURES_HOST
|
||||
) {
|
||||
// send to configured cloud Langfuse instance
|
||||
handler = new CallbackHandler({
|
||||
publicKey: env.LANGFUSE_AI_FEATURES_PUBLIC_KEY,
|
||||
secretKey: env.LANGFUSE_AI_FEATURES_SECRET_KEY,
|
||||
baseUrl: env.LANGFUSE_AI_FEATURES_HOST,
|
||||
environment: traceParams.environment,
|
||||
});
|
||||
|
||||
processTracedEvents = () => Promise.resolve();
|
||||
} else {
|
||||
// use local trace export
|
||||
handler = new CallbackHandler({
|
||||
_projectId: traceParams.projectId,
|
||||
_isLocalEventExportEnabled: true,
|
||||
environment: traceParams.environment,
|
||||
});
|
||||
|
||||
processTracedEvents = async () => {
|
||||
try {
|
||||
const events = await handler.langfuse._exportLocalEvents(
|
||||
traceParams.projectId,
|
||||
);
|
||||
await processEventBatch(
|
||||
JSON.parse(JSON.stringify(events)), // stringify to emulate network event batch from network call
|
||||
traceParams.authCheck,
|
||||
{
|
||||
isLangfuseInternal: context.tracing === "langfuse",
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error("Failed to process traced events", { error: e });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
finalCallbacks.push(handler);
|
||||
}
|
||||
|
||||
finalCallbacks = finalCallbacks.length > 0 ? finalCallbacks : undefined;
|
||||
@@ -174,13 +250,13 @@ export async function fetchLLMCompletion(
|
||||
};
|
||||
|
||||
let finalMessages: BaseMessage[];
|
||||
// VertexAI requires at least 1 user message
|
||||
if (modelParams.adapter === LLMAdapter.VertexAI && messages.length === 1) {
|
||||
const safeContent =
|
||||
typeof messages[0].content === "string"
|
||||
? messages[0].content
|
||||
: JSON.stringify(messages[0].content);
|
||||
finalMessages = [new HumanMessage(safeContent)];
|
||||
// Some providers require at least 1 user message
|
||||
if (
|
||||
messages.length === 1 &&
|
||||
PROVIDERS_WITH_REQUIRED_USER_MESSAGE.includes(modelParams.adapter)
|
||||
) {
|
||||
// Ensure provider schema compliance
|
||||
finalMessages = transformSystemMessageToUserMessage(messages);
|
||||
} else {
|
||||
finalMessages = messages.map((message) => {
|
||||
// For arbitrary content types, convert to string safely
|
||||
@@ -250,7 +326,9 @@ export async function fetchLLMCompletion(
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
temperature: modelParams.temperature,
|
||||
maxTokens: modelParams.max_tokens,
|
||||
...(isOpenAIReasoningModel(modelParams.model as OpenAIModel)
|
||||
? { maxCompletionTokens: modelParams.max_tokens }
|
||||
: { maxTokens: modelParams.max_tokens }),
|
||||
topP: modelParams.top_p,
|
||||
streamUsage: false, // https://github.com/langchain-ai/langchainjs/issues/6533
|
||||
callbacks: finalCallbacks,
|
||||
@@ -282,12 +360,9 @@ export async function fetchLLMCompletion(
|
||||
modelKwargs: modelParams.providerOptions,
|
||||
});
|
||||
} else if (modelParams.adapter === LLMAdapter.Bedrock) {
|
||||
const { region } = BedrockConfigSchema.parse(config);
|
||||
// Handle both explicit credentials and default provider chain
|
||||
const credentials =
|
||||
apiKey === BEDROCK_USE_DEFAULT_CREDENTIALS && !isLangfuseCloud
|
||||
? undefined // undefined = use AWS SDK default credential provider chain
|
||||
: BedrockCredentialSchema.parse(JSON.parse(apiKey));
|
||||
const credentials = resolveBedrockCredentials(apiKey, context);
|
||||
const region = resolveBedrockRegion(context, config);
|
||||
|
||||
chatModel = new ChatBedrockConverse({
|
||||
model: modelParams.model,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
BedrockConfigSchema,
|
||||
VertexAIConfigSchema,
|
||||
} from "../../interfaces/customLLMProviderConfigSchemas";
|
||||
import { TokenCountDelegate } from "../ingestion/processEventBatch";
|
||||
import { AuthHeaderValidVerificationResult } from "../auth/types";
|
||||
import { JSONObjectSchema } from "../../utils/zod";
|
||||
|
||||
@@ -60,6 +59,13 @@ const AnthropicMessageContentWithToolUse = z.union([
|
||||
}),
|
||||
]);
|
||||
|
||||
const GoogleAIStudioMessageContentWithToolUse = z.object({
|
||||
functionCall: z.object({
|
||||
name: z.string(),
|
||||
args: z.unknown(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LLMToolCallSchema = z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
@@ -104,7 +110,11 @@ export const OpenAIResponseFormatSchema = z.object({
|
||||
});
|
||||
|
||||
export const ToolCallResponseSchema = z.object({
|
||||
content: z.union([z.string(), z.array(AnthropicMessageContentWithToolUse)]),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(AnthropicMessageContentWithToolUse),
|
||||
z.array(GoogleAIStudioMessageContentWithToolUse),
|
||||
]),
|
||||
tool_calls: z.array(LLMToolCallSchema),
|
||||
});
|
||||
export type ToolCallResponse = z.infer<typeof ToolCallResponseSchema>;
|
||||
@@ -333,11 +343,63 @@ export const openAIModels = [
|
||||
"gpt-3.5-turbo",
|
||||
] as const;
|
||||
|
||||
type OpenAIReasoningMap = Record<OpenAIModel, boolean>;
|
||||
export const openAIModelToReasoning: OpenAIReasoningMap = {
|
||||
// reasoning models
|
||||
"gpt-5": true,
|
||||
"gpt-5-2025-08-07": true,
|
||||
"gpt-5-mini": true,
|
||||
"gpt-5-mini-2025-08-07": true,
|
||||
"gpt-5-nano": true,
|
||||
"gpt-5-nano-2025-08-07": true,
|
||||
o3: true,
|
||||
"o3-2025-04-16": true,
|
||||
"o4-mini": true,
|
||||
"o4-mini-2025-04-16": true,
|
||||
"o3-mini": true,
|
||||
"o3-mini-2025-01-31": true,
|
||||
"o1-preview": true,
|
||||
"o1-preview-2024-09-12": true,
|
||||
"o1-mini": true,
|
||||
"o1-mini-2024-09-12": true,
|
||||
// non-reasoning models
|
||||
"gpt-4.5-preview": false,
|
||||
"gpt-4.5-preview-2025-02-27": false,
|
||||
"gpt-4-turbo-preview": false,
|
||||
"gpt-4-1106-preview": false,
|
||||
"gpt-4-0613": false,
|
||||
"gpt-4-0125-preview": false,
|
||||
"gpt-4": false,
|
||||
"gpt-3.5-turbo-16k-0613": false,
|
||||
"gpt-3.5-turbo-16k": false,
|
||||
"gpt-3.5-turbo-1106": false,
|
||||
"gpt-3.5-turbo-0613": false,
|
||||
"gpt-3.5-turbo-0301": false,
|
||||
"gpt-3.5-turbo-0125": false,
|
||||
"gpt-3.5-turbo": false,
|
||||
"gpt-4.1": false,
|
||||
"gpt-4.1-2025-04-14": false,
|
||||
"gpt-4.1-mini": false,
|
||||
"gpt-4.1-mini-2025-04-14": false,
|
||||
"gpt-4.1-nano": false,
|
||||
"gpt-4.1-nano-2025-04-14": false,
|
||||
"gpt-4o": false,
|
||||
"gpt-4o-2024-08-06": false,
|
||||
"gpt-4o-2024-05-13": false,
|
||||
"gpt-4o-mini": false,
|
||||
"gpt-4o-mini-2024-07-18": false,
|
||||
};
|
||||
|
||||
export const isOpenAIReasoningModel = (model: OpenAIModel): boolean => {
|
||||
return openAIModelToReasoning[model];
|
||||
};
|
||||
|
||||
export type OpenAIModel = (typeof openAIModels)[number];
|
||||
|
||||
// NOTE: Update docs page when changing this! https://langfuse.com/docs/prompt-management/features/playground#openai-playground--anthropic-playground
|
||||
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
|
||||
export const anthropicModels = [
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-20250514",
|
||||
@@ -439,7 +501,7 @@ export type TraceParams = {
|
||||
traceName: string;
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
environment: PromptExperimentEnvironment;
|
||||
tokenCountDelegate: TokenCountDelegate;
|
||||
// TODO: add more possibilities for environment re: langfuse AI features
|
||||
environment: PromptExperimentEnvironment | string;
|
||||
authCheck: AuthHeaderValidVerificationResult;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,8 @@ class SimpleAttributeMapper implements ObservationTypeMapper {
|
||||
_scopeData?: Record<string, unknown>,
|
||||
): boolean {
|
||||
return (
|
||||
this.attributeKey in attributes && attributes[this.attributeKey] != null
|
||||
this.attributeKey in attributes &&
|
||||
hasMeaningfulValue(attributes[this.attributeKey])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +103,20 @@ class CustomAttributeMapper implements ObservationTypeMapper {
|
||||
}
|
||||
}
|
||||
|
||||
// value is not null, undefined, empty string, or empty object/array
|
||||
function hasMeaningfulValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry to manage observation type mappers with a unified interface to map
|
||||
* span attributes to observation types.
|
||||
@@ -169,6 +184,7 @@ export class ObservationTypeMapperRegistry {
|
||||
{
|
||||
span: "SPAN",
|
||||
generation: "GENERATION",
|
||||
event: "EVENT",
|
||||
embedding: "EMBEDDING",
|
||||
agent: "AGENT",
|
||||
tool: "TOOL",
|
||||
@@ -239,7 +255,7 @@ export class ObservationTypeMapperRegistry {
|
||||
"llm.model_name",
|
||||
"model",
|
||||
];
|
||||
return modelKeys.some((key) => attributes[key] != null);
|
||||
return modelKeys.some((key) => hasMeaningfulValue(attributes[key]));
|
||||
},
|
||||
() => "GENERATION",
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { LangfuseOtelSpanAttributes } from "./attributes";
|
||||
import { ObservationTypeMapperRegistry } from "./ObservationTypeMapper";
|
||||
import { env } from "../../env";
|
||||
import { OtelIngestionQueue } from "../redis/otelIngestionQueue";
|
||||
import { isValidDateString } from "./utils";
|
||||
|
||||
// Type definitions for internal processor state
|
||||
interface TraceState {
|
||||
@@ -512,6 +513,7 @@ export class OtelIngestionProcessor {
|
||||
metadata: {
|
||||
...resourceAttributeMetadata,
|
||||
...this.extractMetadata(attributes, "trace"),
|
||||
...this.extractMetadata(attributes, "observation"),
|
||||
...(isLangfuseSDKSpans
|
||||
? {}
|
||||
: { attributes: spanAttributesInMetadata }),
|
||||
@@ -550,9 +552,10 @@ export class OtelIngestionProcessor {
|
||||
metadata: {
|
||||
...resourceAttributeMetadata,
|
||||
...this.extractMetadata(attributes, "trace"),
|
||||
...(isLangfuseSDKSpans
|
||||
? {}
|
||||
: { attributes: spanAttributesInMetadata }),
|
||||
// removed to not remove trace metadata->attributes through subsequent observations
|
||||
// ...(isLangfuseSDKSpans
|
||||
// ? {}
|
||||
// : { attributes: spanAttributesInMetadata }),
|
||||
resourceAttributes,
|
||||
scope: {
|
||||
...(scopeSpan.scope || {}),
|
||||
@@ -1079,6 +1082,13 @@ export class OtelIngestionProcessor {
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
// GCP Vertex Agent Tool call input and output
|
||||
input = attributes["gcp.vertex.agent.tool_call_args"];
|
||||
output = attributes["gcp.vertex.agent.tool_response"];
|
||||
if (input || output) {
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
// TraceLoop uses attributes property
|
||||
const inputAttributes = Object.keys(attributes).filter((key) =>
|
||||
key.startsWith("gen_ai.prompt"),
|
||||
@@ -1122,12 +1132,12 @@ export class OtelIngestionProcessor {
|
||||
];
|
||||
|
||||
for (const key of environmentAttributeKeys) {
|
||||
if (resourceAttributes[key]) {
|
||||
return resourceAttributes[key] as string;
|
||||
}
|
||||
if (attributes[key]) {
|
||||
return attributes[key] as string;
|
||||
}
|
||||
if (resourceAttributes[key]) {
|
||||
return resourceAttributes[key] as string;
|
||||
}
|
||||
}
|
||||
|
||||
return "default";
|
||||
@@ -1388,6 +1398,7 @@ export class OtelIngestionProcessor {
|
||||
LangfuseOtelSpanAttributes.OBSERVATION_MODEL,
|
||||
"gen_ai.request.model",
|
||||
"gen_ai.response.model",
|
||||
"llm.response.model",
|
||||
"llm.model_name",
|
||||
"model",
|
||||
];
|
||||
@@ -1559,11 +1570,16 @@ export class OtelIngestionProcessor {
|
||||
startTimeISO?: string,
|
||||
): string | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
attributes[
|
||||
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME
|
||||
] as string,
|
||||
);
|
||||
const value = attributes[
|
||||
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME
|
||||
] as any;
|
||||
|
||||
if (isValidDateString(value)) return value;
|
||||
|
||||
// Older SDKs have double stringified timestamps that need JSON parsing
|
||||
// "\"2025-10-01T08:45:26.112648Z\""
|
||||
const parsed = JSON.parse(value);
|
||||
if (isValidDateString(parsed)) return parsed;
|
||||
} catch {
|
||||
// Fallthrough
|
||||
}
|
||||
@@ -1670,14 +1686,7 @@ export class OtelIngestionProcessor {
|
||||
[...traceIds].map(async (traceId) => {
|
||||
const key = `langfuse:project:${this.projectId}:trace:${traceId}:seen`;
|
||||
const TTLSeconds = 600; // 10 minutes
|
||||
const result = await redis?.call(
|
||||
"SET",
|
||||
key,
|
||||
"1",
|
||||
"NX",
|
||||
"EX",
|
||||
TTLSeconds,
|
||||
);
|
||||
const result = await redis?.set(key, "1", "EX", TTLSeconds, "NX");
|
||||
|
||||
return {
|
||||
traceId: traceId,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isValidDateString(dateString: string): boolean {
|
||||
return !isNaN(new Date(dateString).getTime());
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const BatchExportJobSchema = z.object({
|
||||
export const TraceQueueEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
traceId: z.string(),
|
||||
exactTimestamp: z.date().optional(),
|
||||
});
|
||||
export const TracesQueueEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { env } from "../../env";
|
||||
import {
|
||||
clickhouseClient,
|
||||
convertDateToClickhouseDateTime,
|
||||
PreferredClickhouseService,
|
||||
} from "../clickhouse/client";
|
||||
import { logger } from "../logger";
|
||||
import { getTracer, instrumentAsync } from "../instrumentation";
|
||||
@@ -14,6 +15,71 @@ import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "../services/StorageService";
|
||||
import { ClickHouseSettings } from "@clickhouse/client";
|
||||
|
||||
/**
|
||||
* Custom error class for ClickHouse resource-related errors
|
||||
*/
|
||||
// Error type configuration map
|
||||
const ERROR_TYPE_CONFIG: Record<
|
||||
"MEMORY_LIMIT" | "OVERCOMMIT" | "TIMEOUT",
|
||||
{
|
||||
discriminators: string[];
|
||||
}
|
||||
> = {
|
||||
MEMORY_LIMIT: {
|
||||
discriminators: ["memory limit exceeded"],
|
||||
},
|
||||
OVERCOMMIT: {
|
||||
discriminators: ["OvercommitTracker"],
|
||||
},
|
||||
TIMEOUT: {
|
||||
discriminators: ["Timeout", "timeout", "timed out"],
|
||||
},
|
||||
};
|
||||
|
||||
type ErrorType = keyof typeof ERROR_TYPE_CONFIG;
|
||||
|
||||
export class ClickHouseResourceError extends Error {
|
||||
static ERROR_ADVICE_MESSAGE = [
|
||||
"Database resource limit exceeded.",
|
||||
"Please use more specific filters or a shorter time range.",
|
||||
"We are continuously improving our API performance.",
|
||||
].join(" ");
|
||||
|
||||
public readonly errorType: ErrorType;
|
||||
|
||||
constructor(errType: ErrorType, originalError: Error) {
|
||||
super(originalError.message, { cause: originalError });
|
||||
this.name = "ClickHouseResourceError";
|
||||
this.errorType = errType;
|
||||
// Preserve the original stack trace if available
|
||||
if (originalError.stack) {
|
||||
this.stack = originalError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
static wrapIfResourceError(originalError: Error): Error {
|
||||
const errorMessage = originalError.message || "";
|
||||
|
||||
for (const [type, config] of Object.entries(ERROR_TYPE_CONFIG) as Array<
|
||||
[
|
||||
keyof typeof ERROR_TYPE_CONFIG,
|
||||
(typeof ERROR_TYPE_CONFIG)[keyof typeof ERROR_TYPE_CONFIG],
|
||||
]
|
||||
>) {
|
||||
const hasDiscriminator = config.discriminators.some((discriminator) =>
|
||||
errorMessage.includes(discriminator),
|
||||
);
|
||||
|
||||
if (hasDiscriminator) {
|
||||
return new ClickHouseResourceError(type, originalError);
|
||||
}
|
||||
}
|
||||
|
||||
return originalError;
|
||||
}
|
||||
}
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -144,6 +210,8 @@ export async function* queryClickhouseStream<T>(opts: {
|
||||
params?: Record<string, unknown> | undefined;
|
||||
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
|
||||
tags?: Record<string, string>;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
}): AsyncGenerator<T> {
|
||||
const tracer = getTracer("clickhouse-query-stream");
|
||||
const span = tracer.startSpan("clickhouse-query-stream", {
|
||||
@@ -151,20 +219,23 @@ export async function* queryClickhouseStream<T>(opts: {
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await context.with(
|
||||
trace.setSpan(context.active(), span),
|
||||
async () => {
|
||||
const res = await context
|
||||
.with(trace.setSpan(context.active(), span), async () => {
|
||||
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
|
||||
span.setAttribute("ch.query.text", opts.query);
|
||||
span.setAttribute("db.system", "clickhouse");
|
||||
span.setAttribute("db.query.text", opts.query);
|
||||
span.setAttribute("db.operation.name", "SELECT");
|
||||
|
||||
const res = await clickhouseClient(opts.clickhouseConfigs).query({
|
||||
const res = await clickhouseClient(
|
||||
opts.clickhouseConfigs,
|
||||
opts.preferredClickhouseService,
|
||||
).query({
|
||||
query: opts.query,
|
||||
format: "JSONEachRow",
|
||||
query_params: opts.params,
|
||||
clickhouse_settings: {
|
||||
...opts.clickhouseSettings,
|
||||
log_comment: JSON.stringify(opts.tags ?? {}),
|
||||
},
|
||||
});
|
||||
@@ -193,19 +264,58 @@ export async function* queryClickhouseStream<T>(opts: {
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Transform resource errors to provide actionable advice
|
||||
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
|
||||
});
|
||||
|
||||
for await (const rows of res.stream<T>()) {
|
||||
for (const row of rows) {
|
||||
yield row.json();
|
||||
yield handleExceptionRow(row.json());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Also catch errors during streaming
|
||||
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ClickHouse has a quirk when it comes to handling exceptions mid response.
|
||||
* It will simply output a row with "exception" key inside, which is indistinguishable from
|
||||
* a query like `SELECT "my lovely string" AS exception;` may return.
|
||||
*
|
||||
* E.g.:
|
||||
* ```
|
||||
* {"exception":"Code: 395. DB::Exception: memory limit exceeded: would use l0.23 GiB"}
|
||||
* ```
|
||||
*
|
||||
* This function makes the best effort to convert such rows into errors and throws them.
|
||||
*
|
||||
* See:
|
||||
* - https://github.com/ClickHouse/clickhouse-js/issues/332
|
||||
* - https://github.com/ClickHouse/ClickHouse/issues/75175
|
||||
*
|
||||
* Ideally this should get fixed in the future versions of ClickHouse.
|
||||
*/
|
||||
function handleExceptionRow<T>(parsedRow: T): T {
|
||||
if (
|
||||
typeof parsedRow === "object" &&
|
||||
parsedRow !== null &&
|
||||
Object.keys(parsedRow).length === 1 &&
|
||||
"exception" in parsedRow
|
||||
) {
|
||||
const potentialException = (parsedRow as { exception: string }).exception;
|
||||
if (potentialException.match(/^Code: (\d+)/)) {
|
||||
throw new Error(potentialException);
|
||||
}
|
||||
}
|
||||
return parsedRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is retryable (socket hang up, connection reset, etc.)
|
||||
*/
|
||||
@@ -223,6 +333,8 @@ export async function queryClickhouse<T>(opts: {
|
||||
params?: Record<string, unknown> | undefined;
|
||||
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
|
||||
tags?: Record<string, string>;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
}): Promise<T[]> {
|
||||
return await instrumentAsync(
|
||||
{ name: "clickhouse-query", spanKind: SpanKind.CLIENT },
|
||||
@@ -236,11 +348,15 @@ export async function queryClickhouse<T>(opts: {
|
||||
// Retry logic for socket hang up and other network errors
|
||||
return await backOff(
|
||||
async () => {
|
||||
const res = await clickhouseClient(opts.clickhouseConfigs).query({
|
||||
const res = await clickhouseClient(
|
||||
opts.clickhouseConfigs,
|
||||
opts.preferredClickhouseService,
|
||||
).query({
|
||||
query: opts.query,
|
||||
format: "JSONEachRow",
|
||||
query_params: opts.params,
|
||||
clickhouse_settings: {
|
||||
...opts.clickhouseSettings,
|
||||
log_comment: JSON.stringify(opts.tags ?? {}),
|
||||
},
|
||||
});
|
||||
@@ -270,7 +386,7 @@ export async function queryClickhouse<T>(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
return await res.json<T>();
|
||||
return (await res.json<T>()).map(handleExceptionRow);
|
||||
},
|
||||
{
|
||||
numOfAttempts: env.LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS,
|
||||
@@ -304,7 +420,10 @@ export async function queryClickhouse<T>(opts: {
|
||||
timeMultiple: 1,
|
||||
maxDelay: 100,
|
||||
},
|
||||
);
|
||||
).catch((error) => {
|
||||
// Transform resource errors to provide actionable advice
|
||||
throw ClickHouseResourceError.wrapIfResourceError(error as Error);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { DatasetRunItemRecordReadType } from "./definitions";
|
||||
import {
|
||||
DatasetRunItemRecordReadType,
|
||||
DatasetRunItemRecord,
|
||||
} from "./definitions";
|
||||
|
||||
export const convertToDatasetRunMetrics = (row: any) => {
|
||||
return {
|
||||
@@ -15,6 +18,7 @@ export const convertToDatasetRunMetrics = (row: any) => {
|
||||
metadata: row.datasetRunMetadata,
|
||||
countRunItems: row.count_run_items,
|
||||
avgTotalCost: undefined,
|
||||
totalCost: undefined,
|
||||
avgLatency: undefined,
|
||||
scores: undefined,
|
||||
datasetId: row.dataset_id,
|
||||
@@ -56,10 +60,19 @@ export const convertDatasetRunItemDomainToClickhouse = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertDatasetRunItemClickhouseToDomain = (
|
||||
row: DatasetRunItemRecordReadType,
|
||||
): DatasetRunItemDomain => {
|
||||
return {
|
||||
// Function overloads for clean type discrimination
|
||||
export function convertDatasetRunItemClickhouseToDomain(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
row: DatasetRunItemRecord<true>,
|
||||
): DatasetRunItemDomain<true>;
|
||||
export function convertDatasetRunItemClickhouseToDomain(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
row: DatasetRunItemRecord<false>,
|
||||
): DatasetRunItemDomain<false>;
|
||||
export function convertDatasetRunItemClickhouseToDomain<
|
||||
WithIO extends boolean = true,
|
||||
>(row: DatasetRunItemRecord<WithIO>): DatasetRunItemDomain<WithIO> {
|
||||
const baseConversion = {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
traceId: row.trace_id,
|
||||
@@ -70,17 +83,27 @@ export const convertDatasetRunItemClickhouseToDomain = (
|
||||
datasetRunCreatedAt: parseClickhouseUTCDateTimeFormat(
|
||||
row.dataset_run_created_at,
|
||||
),
|
||||
datasetRunMetadata:
|
||||
parseMetadataCHRecordToDomain(row.dataset_run_metadata) ?? null,
|
||||
datasetItemId: row.dataset_item_id,
|
||||
datasetItemInput: row.dataset_item_input,
|
||||
datasetItemExpectedOutput: row.dataset_item_expected_output,
|
||||
datasetItemMetadata: parseMetadataCHRecordToDomain(
|
||||
row.dataset_item_metadata,
|
||||
),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(row.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(row.updated_at),
|
||||
datasetId: row.dataset_id,
|
||||
error: row.error ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// Check if row has IO fields at runtime, typescript does not support conditional types without runtime checks
|
||||
if ("dataset_item_input" in row) {
|
||||
return {
|
||||
...baseConversion,
|
||||
datasetRunMetadata:
|
||||
parseMetadataCHRecordToDomain((row as any).dataset_run_metadata) ??
|
||||
null,
|
||||
datasetItemInput: (row as any).dataset_item_input,
|
||||
datasetItemExpectedOutput: (row as any).dataset_item_expected_output,
|
||||
datasetItemMetadata: parseMetadataCHRecordToDomain(
|
||||
(row as any).dataset_item_metadata,
|
||||
),
|
||||
} as DatasetRunItemDomain<WithIO>;
|
||||
} else {
|
||||
return baseConversion as DatasetRunItemDomain<WithIO>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
queryClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { convertDatasetRunItemClickhouseToDomain } from "./dataset-run-items-converters";
|
||||
import { DatasetRunItemRecordReadType } from "./definitions";
|
||||
import { DatasetRunItemRecord } from "./definitions";
|
||||
import { env } from "../../env";
|
||||
import { commandClickhouse } from "./clickhouse";
|
||||
import Decimal from "decimal.js";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { ScoreAggregate } from "../../features/scores";
|
||||
|
||||
type DatasetItemIdsByTraceIdQuery = {
|
||||
projectId: string;
|
||||
@@ -39,6 +40,23 @@ type DatasetRunItemsTableQuery = {
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions;
|
||||
};
|
||||
|
||||
type BaseDatasetItemWithRunDataQuery = {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
runIds: string[];
|
||||
filterByRun: {
|
||||
runId: string;
|
||||
filters: FilterState;
|
||||
}[];
|
||||
};
|
||||
|
||||
type DatasetItemIdsWithRunDataQuery = BaseDatasetItemWithRunDataQuery & {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type DatasetItemsWithRunDataCountQuery = BaseDatasetItemWithRunDataQuery;
|
||||
|
||||
type DatasetRunItemsByDatasetIdQuery = Omit<
|
||||
DatasetRunItemsTableQuery,
|
||||
"datasetId"
|
||||
@@ -57,6 +75,17 @@ type DatasetRunsMetricsTableQuery = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type BaseDatasetRunItemsWithoutIOQuery = {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
runIds: string[];
|
||||
};
|
||||
|
||||
type DatasetRunItemsByItemIdsWithoutIOQuery =
|
||||
BaseDatasetRunItemsWithoutIOQuery & {
|
||||
datasetItemIds: string[];
|
||||
};
|
||||
|
||||
export type DatasetRunsMetrics = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +93,7 @@ export type DatasetRunsMetrics = {
|
||||
datasetId: string;
|
||||
countRunItems: number;
|
||||
avgTotalCost: Decimal;
|
||||
totalCost: Decimal;
|
||||
avgLatency: number;
|
||||
aggScoresAvg: Array<[string, number]>;
|
||||
aggScoreCategories: string[];
|
||||
@@ -87,6 +117,7 @@ type DatasetRunsMetricsRecordType = {
|
||||
count_run_items: number;
|
||||
avg_latency_seconds: number;
|
||||
avg_total_cost: number;
|
||||
total_cost: number;
|
||||
agg_scores_avg: Array<[string, number]>;
|
||||
agg_score_categories: string[];
|
||||
};
|
||||
@@ -101,6 +132,27 @@ type DatasetRunsRowsRecordType = {
|
||||
dataset_run_metadata: string;
|
||||
};
|
||||
|
||||
export type EnrichedDatasetRunItem = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
datasetItemId: string;
|
||||
datasetRunId: string;
|
||||
datasetRunName: string;
|
||||
observation:
|
||||
| {
|
||||
id: string;
|
||||
latency: number;
|
||||
calculatedTotalCost: Decimal;
|
||||
}
|
||||
| undefined;
|
||||
trace: {
|
||||
id: string;
|
||||
duration: number;
|
||||
totalCost: number;
|
||||
};
|
||||
scores: ScoreAggregate;
|
||||
};
|
||||
|
||||
const convertDatasetRunsMetricsRecord = (
|
||||
record: DatasetRunsMetricsRecordType,
|
||||
): DatasetRunsMetrics => {
|
||||
@@ -113,6 +165,9 @@ const convertDatasetRunsMetricsRecord = (
|
||||
avgTotalCost: record.avg_total_cost
|
||||
? new Decimal(record.avg_total_cost)
|
||||
: new Decimal(0),
|
||||
totalCost: record.total_cost
|
||||
? new Decimal(record.total_cost)
|
||||
: new Decimal(0),
|
||||
avgLatency: record.avg_latency_seconds ?? 0,
|
||||
aggScoresAvg: record.agg_scores_avg ?? [],
|
||||
aggScoreCategories: record.agg_score_categories ?? [],
|
||||
@@ -209,6 +264,10 @@ const getDatasetRunsTableInternal = async <T>(
|
||||
WHEN drm.trace_avg_cost IS NOT NULL THEN drm.trace_avg_cost
|
||||
ELSE COALESCE(drm.obs_avg_cost, 0)
|
||||
END as avg_total_cost,
|
||||
CASE
|
||||
WHEN drm.trace_total_cost IS NOT NULL THEN drm.trace_total_cost
|
||||
ELSE COALESCE(drm.obs_total_cost, 0)
|
||||
END as total_cost,
|
||||
|
||||
-- Score aggregations
|
||||
sa.scores_avg as agg_scores_avg,
|
||||
@@ -365,12 +424,14 @@ const getDatasetRunsTableInternal = async <T>(
|
||||
-- Trace-level metrics (average across traces in this dataset run)
|
||||
AVG(CASE WHEN dri.observation_id IS NULL THEN tm.latency_ms ELSE NULL END) / 1000.0 as trace_avg_latency,
|
||||
AVG(CASE WHEN dri.observation_id IS NULL THEN tm.total_cost ELSE NULL END) as trace_avg_cost,
|
||||
SUM(CASE WHEN dri.observation_id IS NULL THEN tm.total_cost ELSE NULL END) as trace_total_cost,
|
||||
|
||||
-- Observation-level metrics
|
||||
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN
|
||||
dateDiff('millisecond', of.start_time, of.end_time) / 1000.0
|
||||
ELSE NULL END) as obs_avg_latency,
|
||||
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_avg_cost
|
||||
AVG(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_avg_cost,
|
||||
SUM(CASE WHEN dri.observation_id IS NOT NULL THEN of.total_cost ELSE NULL END) as obs_total_cost
|
||||
|
||||
FROM dataset_run_items_rmt dri
|
||||
LEFT JOIN observations_filtered of ON dri.observation_id = of.id
|
||||
@@ -458,13 +519,195 @@ export const getDatasetRunsTableCountCh = async (
|
||||
return Number(rows[0]?.count);
|
||||
};
|
||||
|
||||
const getDatasetRunItemsTableInternal = async <T>(
|
||||
opts: DatasetRunItemsTableQuery & {
|
||||
type GetDatasetRunItemsTableOpts<IncludeIO extends boolean> =
|
||||
DatasetRunItemsTableQuery & {
|
||||
select: "count" | "rows";
|
||||
tags: Record<string, string>;
|
||||
},
|
||||
includeIO?: IncludeIO;
|
||||
};
|
||||
|
||||
// Phase 1: Find dataset item IDs or count that satisfy conditions across ALL runs
|
||||
const getQualifyingDatasetItems = async <T>(opts: {
|
||||
select: "count" | "rows";
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
runIds: string[];
|
||||
runFilters: {
|
||||
runId: string;
|
||||
filters: FilterState;
|
||||
}[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Array<T>> => {
|
||||
const { select, projectId, datasetId, runIds, runFilters, limit, offset } =
|
||||
opts;
|
||||
|
||||
// Build base filter (project + dataset only)
|
||||
const { datasetRunItemsFilter: baseDatasetRunItemsFilter } =
|
||||
getProjectDatasetIdDefaultFilter(projectId, datasetId);
|
||||
const baseFilter = baseDatasetRunItemsFilter.apply();
|
||||
|
||||
// Build run-specific conditions for the intersection query
|
||||
const runFilterResults = runFilters.map((runFilter) => {
|
||||
const { runId, filters: filterState } = runFilter;
|
||||
|
||||
// Create run ID condition
|
||||
const runConditionFilter = new StringFilter({
|
||||
clickhouseTable: "dataset_run_items_rmt",
|
||||
field: "dataset_run_id",
|
||||
operator: "=",
|
||||
value: runId,
|
||||
});
|
||||
|
||||
// Create user filters for this run
|
||||
const userFilters = createFilterFromFilterState(
|
||||
filterState,
|
||||
datasetRunItemsTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
// Combine run condition with user filters using AND and apply immediately
|
||||
const runFilterList = new FilterList([runConditionFilter, ...userFilters]);
|
||||
return runFilterList.apply();
|
||||
});
|
||||
|
||||
// add empty filters for the runs that have no filters
|
||||
runIds.forEach((runId) => {
|
||||
if (runFilters.find((runFilter) => runFilter.runId === runId)) {
|
||||
return;
|
||||
}
|
||||
// Create run ID condition
|
||||
const runConditionFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "dataset_run_items_rmt",
|
||||
field: "dataset_run_id",
|
||||
operator: "=",
|
||||
value: runId,
|
||||
}),
|
||||
]);
|
||||
runFilterResults.push(runConditionFilter.apply());
|
||||
});
|
||||
|
||||
const combinedQuery = `(${runFilterResults.map((result) => `(${result.query})`).join(" OR ")})`;
|
||||
|
||||
const intersectionQuery =
|
||||
runFilters.length > 0
|
||||
? `HAVING COUNT(DISTINCT dataset_run_id) = {totalRunCount: UInt32}`
|
||||
: "";
|
||||
|
||||
// Check if any run has score filters for CTE
|
||||
const hasScoresFilter = runFilters
|
||||
.flatMap((f) => f.filters)
|
||||
.some((f) => f.column.toLowerCase().includes("score"));
|
||||
|
||||
// Build scores filter
|
||||
const scoresFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "scores",
|
||||
field: "project_id",
|
||||
operator: "=",
|
||||
value: projectId,
|
||||
}),
|
||||
]);
|
||||
const appliedScoresFilter = scoresFilter.apply();
|
||||
|
||||
const selectString =
|
||||
select === "count"
|
||||
? "COUNT(DISTINCT dataset_item_id) as count"
|
||||
: "dataset_item_id";
|
||||
|
||||
// Build the intersection query
|
||||
const scoresCte = hasScoresFilter
|
||||
? `
|
||||
WITH scores_aggregated AS (
|
||||
SELECT
|
||||
dri.dataset_run_id,
|
||||
dri.project_id,
|
||||
dri.trace_id,
|
||||
-- For numeric scores, use tuples of (name, avg_value)
|
||||
groupArrayIf(
|
||||
tuple(s.name, s.avg_value),
|
||||
s.data_type IN ('NUMERIC', 'BOOLEAN')
|
||||
) AS scores_avg,
|
||||
-- For categorical scores, use name:value format for improved query performance
|
||||
groupArrayIf(
|
||||
concat(s.name, ':', s.string_value),
|
||||
s.data_type = 'CATEGORICAL' AND notEmpty(s.string_value)
|
||||
) AS score_categories
|
||||
FROM dataset_run_items_rmt dri
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value,
|
||||
avg(value) as avg_value
|
||||
FROM scores s FINAL
|
||||
WHERE ${appliedScoresFilter.query}
|
||||
GROUP BY
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value
|
||||
) s ON s.project_id = dri.project_id AND s.trace_id = dri.trace_id
|
||||
WHERE ${baseFilter.query}
|
||||
GROUP BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.trace_id
|
||||
),
|
||||
`
|
||||
: "WITH ";
|
||||
|
||||
const query = `
|
||||
${scoresCte}
|
||||
run_qualified_items AS (
|
||||
SELECT DISTINCT dri.dataset_item_id, dri.dataset_run_id
|
||||
FROM dataset_run_items_rmt dri
|
||||
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
|
||||
WHERE ${baseFilter.query}
|
||||
AND ${combinedQuery}
|
||||
),
|
||||
intersection_items AS (
|
||||
SELECT dataset_item_id
|
||||
FROM run_qualified_items
|
||||
GROUP BY dataset_item_id
|
||||
${intersectionQuery}
|
||||
)
|
||||
SELECT
|
||||
${selectString}
|
||||
FROM intersection_items
|
||||
${select === "count" ? "" : "ORDER BY dataset_item_id -- for consistent pagination"}
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query,
|
||||
params: {
|
||||
...baseFilter.params,
|
||||
...(hasScoresFilter ? appliedScoresFilter.params : {}),
|
||||
totalRunCount: runIds.length,
|
||||
...runFilterResults.reduce((acc, result) => {
|
||||
return { ...acc, ...result.params };
|
||||
}, {}),
|
||||
...(limit !== undefined && offset !== undefined ? { limit, offset } : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
projectId,
|
||||
datasetId,
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const getDatasetRunItemsTableInternal = async <
|
||||
T,
|
||||
IncludeIO extends boolean = true,
|
||||
>(
|
||||
opts: GetDatasetRunItemsTableOpts<IncludeIO>,
|
||||
): Promise<Array<T>> => {
|
||||
const { projectId, datasetId, filter, orderBy, limit, offset } = opts;
|
||||
const { projectId, datasetId, filter, orderBy, limit, offset, includeIO } =
|
||||
opts;
|
||||
|
||||
let selectString = "";
|
||||
|
||||
@@ -487,11 +730,11 @@ const getDatasetRunItemsTableInternal = async <T>(
|
||||
dri.updated_at as updated_at,
|
||||
dri.dataset_run_name as dataset_run_name,
|
||||
dri.dataset_run_description as dataset_run_description,
|
||||
dri.dataset_run_metadata as dataset_run_metadata,
|
||||
dri.dataset_run_created_at as dataset_run_created_at,
|
||||
dri.dataset_item_input as dataset_item_input,
|
||||
dri.dataset_item_expected_output as dataset_item_expected_output,
|
||||
dri.dataset_item_metadata as dataset_item_metadata,
|
||||
${includeIO ? "dri.dataset_run_metadata as dataset_run_metadata, " : ""}
|
||||
${includeIO ? "dri.dataset_item_input as dataset_item_input, " : ""}
|
||||
${includeIO ? "dri.dataset_item_expected_output as dataset_item_expected_output, " : ""}
|
||||
${includeIO ? "dri.dataset_item_metadata as dataset_item_metadata, " : ""}
|
||||
dri.is_deleted as is_deleted,
|
||||
dri.event_ts as event_ts`;
|
||||
break;
|
||||
@@ -512,6 +755,21 @@ const getDatasetRunItemsTableInternal = async <T>(
|
||||
);
|
||||
const appliedFilter = datasetRunItemsFilter.apply();
|
||||
|
||||
const scoresFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "scores",
|
||||
field: "project_id",
|
||||
operator: "=",
|
||||
value: projectId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const hasScoresFilter = filter.some((f) =>
|
||||
f.column.toLowerCase().includes("score"),
|
||||
);
|
||||
|
||||
const appliedScoresFilter = scoresFilter.apply();
|
||||
|
||||
// Build ORDER BY array - conditionally add event_ts DESC for rows
|
||||
const orderByArray: OrderByState[] = [];
|
||||
|
||||
@@ -537,30 +795,77 @@ const getDatasetRunItemsTableInternal = async <T>(
|
||||
datasetRunItemsTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
const scoresCte = `
|
||||
WITH scores_aggregated AS (
|
||||
SELECT
|
||||
dri.dataset_run_id,
|
||||
dri.project_id,
|
||||
dri.trace_id,
|
||||
-- For numeric scores, use tuples of (name, avg_value)
|
||||
groupArrayIf(
|
||||
tuple(s.name, s.avg_value),
|
||||
s.data_type IN ('NUMERIC', 'BOOLEAN')
|
||||
) AS scores_avg,
|
||||
-- For categorical scores, use name:value format for improved query performance
|
||||
groupArrayIf(
|
||||
concat(s.name, ':', s.string_value),
|
||||
s.data_type = 'CATEGORICAL' AND notEmpty(s.string_value)
|
||||
) AS score_categories
|
||||
FROM dataset_run_items_rmt dri
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value,
|
||||
avg(value) as avg_value
|
||||
FROM scores s FINAL
|
||||
WHERE ${appliedScoresFilter.query}
|
||||
GROUP BY
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value
|
||||
) s ON s.project_id = dri.project_id AND s.trace_id = dri.trace_id
|
||||
WHERE dri.project_id = {projectId: String}
|
||||
${datasetId ? "AND dri.dataset_id = {datasetId: String}" : ""}
|
||||
GROUP BY dri.dataset_run_id, dri.project_id, dri.trace_id
|
||||
)
|
||||
`;
|
||||
|
||||
const query =
|
||||
opts.select === "rows"
|
||||
? `
|
||||
${scoresCte}
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
${selectString}
|
||||
FROM dataset_run_items_rmt dri
|
||||
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
|
||||
WHERE ${appliedFilter.query}
|
||||
${orderByClause}
|
||||
LIMIT 1 BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id
|
||||
) AS deduplicated
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`
|
||||
: `
|
||||
${scoresCte}
|
||||
SELECT
|
||||
${selectString}
|
||||
FROM dataset_run_items_rmt dri
|
||||
${hasScoresFilter ? `LEFT JOIN scores_aggregated sa ON dri.dataset_run_id = sa.dataset_run_id AND dri.project_id = sa.project_id AND dri.trace_id = sa.trace_id` : ""}
|
||||
WHERE ${appliedFilter.query};`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query,
|
||||
params: {
|
||||
...appliedFilter.params,
|
||||
...appliedScoresFilter.params,
|
||||
...(limit !== undefined && offset !== undefined ? { limit, offset } : {}),
|
||||
...(datasetId ? { datasetId } : {}),
|
||||
projectId,
|
||||
},
|
||||
tags: {
|
||||
...(opts.tags ?? {}),
|
||||
@@ -578,27 +883,87 @@ const getDatasetRunItemsTableInternal = async <T>(
|
||||
export const getDatasetRunItemsCh = async (
|
||||
opts: DatasetRunItemsTableQuery,
|
||||
): Promise<DatasetRunItemDomain[]> => {
|
||||
const rows =
|
||||
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
|
||||
...opts,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
const rows = await getDatasetRunItemsTableInternal<DatasetRunItemRecord>({
|
||||
...opts,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
return rows.map(convertDatasetRunItemClickhouseToDomain);
|
||||
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsByDatasetIdCh = async (
|
||||
opts: DatasetRunItemsByDatasetIdQuery,
|
||||
): Promise<DatasetRunItemDomain[]> => {
|
||||
const rows =
|
||||
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
|
||||
...opts,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
const rows = await getDatasetRunItemsTableInternal<DatasetRunItemRecord>({
|
||||
...opts,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
return rows.map(convertDatasetRunItemClickhouseToDomain);
|
||||
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
|
||||
};
|
||||
|
||||
export const getDatasetItemsWithRunDataCount = async (
|
||||
opts: DatasetItemsWithRunDataCountQuery,
|
||||
): Promise<number> => {
|
||||
const { projectId, datasetId, runIds, filterByRun } = opts;
|
||||
|
||||
const rows = await getQualifyingDatasetItems<{ count: string }>({
|
||||
select: "count",
|
||||
projectId,
|
||||
datasetId,
|
||||
runIds,
|
||||
runFilters: filterByRun,
|
||||
});
|
||||
|
||||
return Number(rows[0]?.count);
|
||||
};
|
||||
|
||||
export const getDatasetItemIdsWithRunData = async (
|
||||
opts: DatasetItemIdsWithRunDataQuery,
|
||||
): Promise<string[]> => {
|
||||
const rows = await getQualifyingDatasetItems<{ dataset_item_id: string }>({
|
||||
select: "rows",
|
||||
runFilters: opts.filterByRun,
|
||||
...opts,
|
||||
});
|
||||
|
||||
return rows.map((row) => row.dataset_item_id);
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsWithoutIOByItemIds = async (
|
||||
opts: DatasetRunItemsByItemIdsWithoutIOQuery,
|
||||
): Promise<DatasetRunItemDomain<false>[]> => {
|
||||
// Step 1: Get DRI data matching [datasetId, runId, datasetItemId]
|
||||
const { datasetItemIds, runIds, ...rest } = opts;
|
||||
|
||||
const filter: FilterState = [
|
||||
{
|
||||
column: "datasetItemId",
|
||||
operator: "any of",
|
||||
value: datasetItemIds,
|
||||
type: "stringOptions" as const,
|
||||
},
|
||||
{
|
||||
column: "datasetRunId",
|
||||
operator: "any of",
|
||||
value: runIds,
|
||||
type: "stringOptions" as const,
|
||||
},
|
||||
];
|
||||
const rows = await getDatasetRunItemsTableInternal<
|
||||
DatasetRunItemRecord<false>,
|
||||
false
|
||||
>({
|
||||
...rest,
|
||||
filter,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
// Step 2: Convert to domain
|
||||
return rows.map((row) => convertDatasetRunItemClickhouseToDomain(row));
|
||||
};
|
||||
|
||||
export const getDatasetItemIdsByTraceIdCh = async (
|
||||
|
||||
@@ -225,6 +225,17 @@ const datasetRunItemRecordReadSchema = datasetRunItemRecordBaseSchema.extend({
|
||||
export type DatasetRunItemRecordReadType = z.infer<
|
||||
typeof datasetRunItemRecordReadSchema
|
||||
>;
|
||||
// Conditional type for dataset run item records with optional IO
|
||||
export type DatasetRunItemRecord<WithIO extends boolean = true> =
|
||||
WithIO extends true
|
||||
? DatasetRunItemRecordReadType
|
||||
: Omit<
|
||||
DatasetRunItemRecordReadType,
|
||||
| "dataset_run_metadata"
|
||||
| "dataset_item_input"
|
||||
| "dataset_item_expected_output"
|
||||
| "dataset_item_metadata"
|
||||
>;
|
||||
|
||||
export const datasetRunItemRecordInsertSchema =
|
||||
datasetRunItemRecordBaseSchema.extend({
|
||||
@@ -496,128 +507,3 @@ export const convertPostgresScoreToInsert = (
|
||||
is_deleted: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertTraceToTraceNull = (
|
||||
traceRecord: TraceRecordInsertType,
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: traceRecord.project_id,
|
||||
id: traceRecord.id,
|
||||
start_time: traceRecord.timestamp,
|
||||
end_time: null, // traces don't have end_time, will be null
|
||||
name: traceRecord.name || null,
|
||||
|
||||
// Metadata properties
|
||||
metadata: traceRecord.metadata,
|
||||
user_id: traceRecord.user_id || null,
|
||||
session_id: traceRecord.session_id || null,
|
||||
environment: traceRecord.environment,
|
||||
tags: traceRecord.tags,
|
||||
version: traceRecord.version || null,
|
||||
release: traceRecord.release || null,
|
||||
|
||||
// UI properties - nullable to prevent absent values being interpreted as overwrites
|
||||
bookmarked: traceRecord.bookmarked ?? null,
|
||||
public: traceRecord.public ?? null,
|
||||
|
||||
// Aggregations - empty for now, will be populated by aggregation processes
|
||||
observation_ids: [],
|
||||
score_ids: [],
|
||||
cost_details: {},
|
||||
usage_details: {},
|
||||
|
||||
// Input/Output
|
||||
input: traceRecord.input || "",
|
||||
output: traceRecord.output || "",
|
||||
|
||||
created_at: traceRecord.created_at,
|
||||
updated_at: traceRecord.updated_at,
|
||||
event_ts: traceRecord.event_ts,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertObservationToTraceNull = (
|
||||
observationRecord: ObservationRecordInsertType,
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: observationRecord.project_id,
|
||||
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
|
||||
id: observationRecord.trace_id || "",
|
||||
start_time: observationRecord.start_time,
|
||||
end_time: observationRecord.end_time || null,
|
||||
name: null,
|
||||
|
||||
// Metadata properties
|
||||
metadata: {},
|
||||
user_id: null,
|
||||
session_id: null,
|
||||
environment: observationRecord.environment,
|
||||
tags: [],
|
||||
version: null,
|
||||
release: null,
|
||||
|
||||
// UI properties - nullable to prevent absent values being interpreted as overwrites
|
||||
bookmarked: null,
|
||||
public: null,
|
||||
|
||||
// Aggregations - include this observation ID
|
||||
observation_ids: [observationRecord.id],
|
||||
score_ids: [],
|
||||
// We can fill the cost details here, but we shouldn't trust them.
|
||||
// Only used for verification to estimate how big the double-counting is.
|
||||
// Actually, we don't as this will make backfills challenging.
|
||||
cost_details: {}, // observationRecord.cost_details || {},
|
||||
usage_details: {}, // observationRecord.usage_details || {},
|
||||
|
||||
// Input/Output
|
||||
input: "",
|
||||
output: "",
|
||||
|
||||
created_at: observationRecord.created_at,
|
||||
updated_at: observationRecord.updated_at,
|
||||
event_ts: observationRecord.event_ts,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertScoreToTraceNull = (
|
||||
scoreRecord: ScoreRecordInsertType,
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: scoreRecord.project_id,
|
||||
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
|
||||
id: scoreRecord.trace_id || "",
|
||||
start_time: scoreRecord.timestamp,
|
||||
end_time: null, // scores don't have end_time
|
||||
name: null,
|
||||
|
||||
// Metadata properties
|
||||
metadata: {},
|
||||
user_id: null,
|
||||
session_id: null,
|
||||
environment: scoreRecord.environment,
|
||||
tags: [], // scores don't have tags
|
||||
version: null, // scores don't have version
|
||||
release: null, // scores don't have release
|
||||
|
||||
// UI properties - nullable to prevent absent values being interpreted as overwrites
|
||||
bookmarked: null,
|
||||
public: null,
|
||||
|
||||
// Aggregations - include this score ID
|
||||
observation_ids: [],
|
||||
score_ids: [scoreRecord.id],
|
||||
cost_details: {},
|
||||
usage_details: {},
|
||||
|
||||
// Input/Output
|
||||
input: "", // scores don't have input
|
||||
output: "", // scores don't have output
|
||||
|
||||
created_at: scoreRecord.created_at,
|
||||
updated_at: scoreRecord.updated_at,
|
||||
event_ts: scoreRecord.event_ts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,24 +2,33 @@ import { queryClickhouse } from "./clickhouse";
|
||||
|
||||
export type EnvironmentFilterProps = {
|
||||
projectId: string;
|
||||
fromTimestamp?: Date;
|
||||
};
|
||||
|
||||
export const getEnvironmentsForProject = async (
|
||||
props: EnvironmentFilterProps,
|
||||
): Promise<{ environment: string }[]> => {
|
||||
const { projectId } = props;
|
||||
const { projectId, fromTimestamp } = props;
|
||||
|
||||
const query = `
|
||||
SELECT environments
|
||||
FROM project_environments FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
(
|
||||
SELECT distinct environment
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
${fromTimestamp ? "AND timestamp >= {fromTimestamp: DateTime64(3)}" : ""}
|
||||
) UNION ALL (
|
||||
SELECT distinct environment
|
||||
FROM observations
|
||||
WHERE project_id = {projectId: String}
|
||||
${fromTimestamp ? "AND start_time >= {fromTimestamp: DateTime64(3)}" : ""}
|
||||
)
|
||||
`;
|
||||
|
||||
const results = await queryClickhouse<{
|
||||
environments: string[];
|
||||
environment: string;
|
||||
}>({
|
||||
query,
|
||||
params: { projectId },
|
||||
params: { projectId, fromTimestamp },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "environment",
|
||||
@@ -27,10 +36,12 @@ export const getEnvironmentsForProject = async (
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
// Always add default environment to list
|
||||
results.push({ environment: "default" });
|
||||
|
||||
const environments = results.length > 0 ? results[0].environments : [];
|
||||
environments.push("default");
|
||||
return Array.from(new Set(environments)).map((environment) => ({
|
||||
environment,
|
||||
}));
|
||||
return Array.from(new Set(results.map((e) => e.environment))).map(
|
||||
(environment) => ({
|
||||
environment,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { getTimeframesTracesAMT, getTracesByIds } from "./traces";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import {
|
||||
convertDateToClickhouseDateTime,
|
||||
PreferredClickhouseService,
|
||||
} from "../clickhouse/client";
|
||||
import { convertObservation } from "./observations_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
import {
|
||||
@@ -120,12 +123,19 @@ export type GetObservationsForTraceOpts<IncludeIO extends boolean> = {
|
||||
projectId: string;
|
||||
timestamp?: Date;
|
||||
includeIO?: IncludeIO;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
};
|
||||
|
||||
export const getObservationsForTrace = async <IncludeIO extends boolean>(
|
||||
opts: GetObservationsForTraceOpts<IncludeIO>,
|
||||
) => {
|
||||
const { traceId, projectId, timestamp, includeIO = false } = opts;
|
||||
const {
|
||||
traceId,
|
||||
projectId,
|
||||
timestamp,
|
||||
includeIO = false,
|
||||
preferredClickhouseService,
|
||||
} = opts;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -178,6 +188,7 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
|
||||
kind: "list",
|
||||
projectId,
|
||||
},
|
||||
preferredClickhouseService,
|
||||
});
|
||||
|
||||
// Large number of observations in trace with large input / output / metadata will lead to
|
||||
@@ -307,6 +318,7 @@ export const getObservationById = async ({
|
||||
type,
|
||||
traceId,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
preferredClickhouseService,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -315,6 +327,7 @@ export const getObservationById = async ({
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
renderingProps?: RenderingProps;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
}) => {
|
||||
const records = await getObservationByIdInternal({
|
||||
id,
|
||||
@@ -324,6 +337,7 @@ export const getObservationById = async ({
|
||||
type,
|
||||
traceId,
|
||||
renderingProps,
|
||||
preferredClickhouseService,
|
||||
});
|
||||
const mapped = records.map((record) =>
|
||||
convertObservation(record, renderingProps),
|
||||
@@ -408,6 +422,7 @@ const getObservationByIdInternal = async ({
|
||||
type,
|
||||
traceId,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
preferredClickhouseService,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -416,6 +431,7 @@ const getObservationByIdInternal = async ({
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
renderingProps?: RenderingProps;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
}) => {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -472,6 +488,7 @@ const getObservationByIdInternal = async ({
|
||||
kind: "byId",
|
||||
projectId,
|
||||
},
|
||||
preferredClickhouseService,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DEFAULT_RENDERING_PROPS,
|
||||
applyInputOutputRendering,
|
||||
} from "../utils/rendering";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const convertObservation = (
|
||||
record: ObservationRecordReadType,
|
||||
@@ -19,6 +20,18 @@ export const convertObservation = (
|
||||
const reducedCostDetails = reduceUsageOrCostDetails(record.cost_details);
|
||||
const reducedUsageDetails = reduceUsageOrCostDetails(record.usage_details);
|
||||
|
||||
if (!record.start_time) {
|
||||
logger.error(
|
||||
`Found invalid value start_time: ${record.start_time} for record ${record.id} in project ${record.project_id}. Processing will fail.`,
|
||||
{
|
||||
...record,
|
||||
input: null,
|
||||
output: null,
|
||||
metadata: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
traceId: record.trace_id ?? null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScoreSourceType } from "../../domain";
|
||||
import { PreferredClickhouseService } from "../clickhouse/client";
|
||||
import { queryClickhouse } from "./clickhouse";
|
||||
import { ScoreRecordReadType } from "./definitions";
|
||||
import { convertToScore } from "./scores_converters";
|
||||
@@ -13,11 +14,13 @@ export const _handleGetScoreById = async ({
|
||||
scoreId,
|
||||
source,
|
||||
scoreScope,
|
||||
preferredClickhouseService,
|
||||
}: {
|
||||
projectId: string;
|
||||
scoreId: string;
|
||||
source?: ScoreSourceType;
|
||||
scoreScope: "traces_only" | "all";
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
}) => {
|
||||
const query = `
|
||||
SELECT *
|
||||
@@ -44,6 +47,7 @@ export const _handleGetScoreById = async ({
|
||||
kind: "byId",
|
||||
projectId,
|
||||
},
|
||||
preferredClickhouseService,
|
||||
});
|
||||
return rows.map(convertToScore).shift();
|
||||
};
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
ScoreAggregation,
|
||||
} from "./scores_converters";
|
||||
import { SCORE_TO_TRACE_OBSERVATIONS_INTERVAL } from "./constants";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import {
|
||||
convertDateToClickhouseDateTime,
|
||||
PreferredClickhouseService,
|
||||
} from "../clickhouse/client";
|
||||
import { ScoreRecordReadType } from "./definitions";
|
||||
import { env } from "../../env";
|
||||
import { _handleGetScoreById, _handleGetScoresByIds } from "./scores-utils";
|
||||
@@ -152,6 +155,7 @@ export type GetScoresForTracesProps<
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions;
|
||||
excludeMetadata?: ExcludeMetadata;
|
||||
includeHasMetadata?: IncludeHasMetadata;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
};
|
||||
|
||||
type GetScoresForSessionsProps<
|
||||
@@ -375,6 +379,7 @@ export const getScoresForTraces = async <
|
||||
clickhouseConfigs,
|
||||
excludeMetadata = false,
|
||||
includeHasMetadata = false,
|
||||
preferredClickhouseService,
|
||||
} = props;
|
||||
|
||||
const select = formatMetadataSelect(excludeMetadata, includeHasMetadata);
|
||||
@@ -417,6 +422,7 @@ export const getScoresForTraces = async <
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs,
|
||||
preferredClickhouseService,
|
||||
});
|
||||
|
||||
return rows.map((row) => {
|
||||
@@ -1447,6 +1453,9 @@ export const getScoresForPostHog = async function* (
|
||||
s.data_type as data_type,
|
||||
s.comment as comment,
|
||||
s.environment as environment,
|
||||
s.trace_id as score_trace_id,
|
||||
s.session_id as score_session_id,
|
||||
s.dataset_run_id as score_dataset_run_id,
|
||||
t.id as trace_id,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
@@ -1458,11 +1467,21 @@ export const getScoresForPostHog = async function* (
|
||||
FROM scores s FINAL
|
||||
LEFT JOIN ${traceTable} t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
|
||||
WHERE s.project_id = {projectId: String}
|
||||
AND t.project_id = {projectId: String}
|
||||
AND s.timestamp >= {minTimestamp: DateTime64(3)}
|
||||
AND s.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
AND (
|
||||
s.trace_id IS NOT NULL
|
||||
OR s.session_id IS NOT NULL
|
||||
OR s.dataset_run_id IS NOT NULL
|
||||
)
|
||||
AND (
|
||||
t.project_id = '' -- use the default value for the string type to filter for absence
|
||||
OR (
|
||||
t.project_id = {projectId: String}
|
||||
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
|
||||
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
@@ -1490,6 +1509,13 @@ export const getScoresForPostHog = async function* (
|
||||
|
||||
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
|
||||
for await (const record of records) {
|
||||
// Determine the effective session_id based on score attachment
|
||||
const effectiveSessionId =
|
||||
record.score_session_id || record.trace_session_id;
|
||||
|
||||
// Determine the effective trace_id (could be null for session-only or dataset-run-only scores)
|
||||
const effectiveTraceId = record.score_trace_id || null;
|
||||
|
||||
yield {
|
||||
timestamp: record.timestamp,
|
||||
langfuse_score_name: record.name,
|
||||
@@ -1499,15 +1525,23 @@ export const getScoresForPostHog = async function* (
|
||||
langfuse_score_string_value: record.string_value,
|
||||
langfuse_score_data_type: record.data_type,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_trace_id: record.trace_id,
|
||||
langfuse_trace_id: effectiveTraceId,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_session_id: effectiveSessionId,
|
||||
langfuse_project_id: projectId,
|
||||
langfuse_user_id: record.trace_user_id || null,
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
langfuse_score_entity_type: record.score_trace_id
|
||||
? "trace"
|
||||
: record.score_session_id
|
||||
? "session"
|
||||
: record.score_dataset_run_id
|
||||
? "dataset_run"
|
||||
: "unknown",
|
||||
langfuse_dataset_run_id: record.score_dataset_run_id,
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
...(record.trace_user_id
|
||||
? {
|
||||
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
FilterList,
|
||||
StringFilter,
|
||||
} from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
import { TraceRecordReadType, convertTraceToTraceNull } from "./definitions";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { tracesTableUiColumnDefinitions } from "../tableMappings/mapTracesTable";
|
||||
import { UiColumnMappings } from "../../tableDefinitions";
|
||||
import {
|
||||
clickhouseClient,
|
||||
convertDateToClickhouseDateTime,
|
||||
PreferredClickhouseService,
|
||||
} from "../clickhouse/client";
|
||||
import { convertClickhouseToDomain } from "./traces_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
@@ -57,6 +57,7 @@ export const getTimeframesTracesAMT = (
|
||||
): TracesAMTs => {
|
||||
if (!fromTimestamp) {
|
||||
// The TracesAllAMT must always be returned if there is no timestamp.
|
||||
console.log("No timestamp provided, returning TracesAllAMT");
|
||||
return TracesAMTs.TracesAllAMT;
|
||||
}
|
||||
|
||||
@@ -89,6 +90,8 @@ export const getTimeframesTracesAMT = (
|
||||
* @param {string} traceId - ID of the trace to check
|
||||
* @param {Date} timestamp - Timestamp for time-based filtering, uses event payload or job timestamp
|
||||
* @param {FilterState} filter - Filter for the trace
|
||||
* @param {Date} maxTimeStamp - Upper bound on timestamp
|
||||
* @param {Date} exactTimestamp - Exact match for the trace
|
||||
* @returns {Promise<boolean>} - True if trace exists
|
||||
*
|
||||
* Notes:
|
||||
@@ -197,10 +200,10 @@ export const checkTraceExistsAndGetTimestamp = async ({
|
||||
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
|
||||
WHERE ${tracesFilterRes.query}
|
||||
AND t.project_id = {projectId: String}
|
||||
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
|
||||
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
|
||||
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
|
||||
AND t.timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
${maxTimeStamp ? `AND t.timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
|
||||
${!maxTimeStamp ? `AND t.timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
|
||||
${exactTimestamp ? `AND toDate(t.timestamp) = toDate({exactTimestamp: DateTime64(3)})` : ""}
|
||||
GROUP BY t.id, t.project_id, t.timestamp
|
||||
`;
|
||||
|
||||
@@ -278,38 +281,6 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
|
||||
projectId: trace.project_id ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Also insert into traces_null if experiment flag is enabled
|
||||
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
|
||||
// Convert trace to insert format first (since we have read format)
|
||||
const traceRecord = trace as TraceRecordReadType;
|
||||
const traceInsert = {
|
||||
...traceRecord,
|
||||
timestamp: new Date(traceRecord.timestamp).getTime(),
|
||||
created_at: new Date(traceRecord.created_at).getTime(),
|
||||
updated_at: new Date(traceRecord.updated_at).getTime(),
|
||||
event_ts: new Date(traceRecord.event_ts).getTime(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
// Convert to traces_null format
|
||||
const traceNull = convertTraceToTraceNull(traceInsert);
|
||||
|
||||
// Insert directly into traces_null using clickhouse client
|
||||
await clickhouseClient().insert({
|
||||
table: "traces_null",
|
||||
format: "JSONEachRow",
|
||||
values: [traceNull],
|
||||
clickhouse_settings: {
|
||||
log_comment: JSON.stringify({
|
||||
feature: "tracing",
|
||||
type: "traces_null",
|
||||
kind: "upsert",
|
||||
experiment: "insert_into_aggregating_merge_trees",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getTracesByIds = async (
|
||||
@@ -691,6 +662,7 @@ export const getTraceById = async ({
|
||||
fromTimestamp,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
clickhouseFeatureTag = "tracing",
|
||||
preferredClickhouseService,
|
||||
}: {
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
@@ -698,6 +670,7 @@ export const getTraceById = async ({
|
||||
fromTimestamp?: Date;
|
||||
renderingProps?: RenderingProps;
|
||||
clickhouseFeatureTag?: string;
|
||||
preferredClickhouseService?: PreferredClickhouseService;
|
||||
}) => {
|
||||
const records = await measureAndReturn({
|
||||
operationName: "getTraceById",
|
||||
@@ -756,6 +729,7 @@ export const getTraceById = async ({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
preferredClickhouseService,
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
@@ -791,6 +765,7 @@ export const getTraceById = async ({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
preferredClickhouseService,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,11 +24,18 @@ type UploadFile = {
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
data: Readable | string;
|
||||
};
|
||||
|
||||
type UploadWithSignedUrl = UploadFile & {
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export interface StorageService {
|
||||
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>; // eslint-disable-line no-unused-vars
|
||||
uploadFile(params: UploadFile): Promise<void>; // eslint-disable-line no-unused-vars
|
||||
|
||||
uploadWithSignedUrl(
|
||||
params: UploadWithSignedUrl, // eslint-disable-line no-unused-vars
|
||||
): Promise<{ signedUrl: string }>;
|
||||
|
||||
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>; // eslint-disable-line no-unused-vars
|
||||
|
||||
@@ -161,8 +168,8 @@ class AzureBlobStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadFile(params: UploadFile): Promise<{ signedUrl: string }> {
|
||||
const { fileName, data, expiresInSeconds } = params;
|
||||
public async uploadFile(params: UploadFile): Promise<void> {
|
||||
const { fileName, data } = params;
|
||||
try {
|
||||
await this.createContainerIfNotExists();
|
||||
|
||||
@@ -196,6 +203,23 @@ class AzureBlobStorageService implements StorageService {
|
||||
throw new Error("Unsupported data type. Must be Readable or string.");
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to upload file to Azure Blob Storage ${fileName}`,
|
||||
err,
|
||||
);
|
||||
throw Error("Failed to upload file to Azure Blob Storage");
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadWithSignedUrl(
|
||||
params: UploadWithSignedUrl,
|
||||
): Promise<{ signedUrl: string }> {
|
||||
const { fileName, data, fileType, expiresInSeconds } = params;
|
||||
try {
|
||||
await this.uploadFile({ fileName, data, fileType });
|
||||
|
||||
return {
|
||||
signedUrl: await this.getSignedUrl(fileName, expiresInSeconds, false),
|
||||
};
|
||||
@@ -454,8 +478,7 @@ class S3StorageService implements StorageService {
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
}: UploadFile): Promise<{ signedUrl: string }> {
|
||||
}: UploadFile): Promise<void> {
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.client,
|
||||
@@ -467,6 +490,22 @@ class S3StorageService implements StorageService {
|
||||
}),
|
||||
}).done();
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to upload file to ${fileName}`, err);
|
||||
throw new Error(`Failed to upload to S3: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadWithSignedUrl({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
}: UploadWithSignedUrl): Promise<{ signedUrl: string }> {
|
||||
try {
|
||||
await this.uploadFile({ fileName, data, fileType });
|
||||
|
||||
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
|
||||
|
||||
return { signedUrl };
|
||||
@@ -663,8 +702,7 @@ class GoogleCloudStorageService implements StorageService {
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
}: UploadFile): Promise<{ signedUrl: string }> {
|
||||
}: UploadFile): Promise<void> {
|
||||
try {
|
||||
const file = this.bucket.file(fileName);
|
||||
const options = {
|
||||
@@ -674,27 +712,18 @@ class GoogleCloudStorageService implements StorageService {
|
||||
|
||||
if (typeof data === "string") {
|
||||
await file.save(data, options);
|
||||
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
|
||||
return { signedUrl };
|
||||
return;
|
||||
} else if (data instanceof Readable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const writeStream = file.createWriteStream(options);
|
||||
|
||||
data
|
||||
.pipe(writeStream)
|
||||
.on("error", (err) => {
|
||||
.on("error", (err: unknown) => {
|
||||
reject(err);
|
||||
})
|
||||
.on("finish", async () => {
|
||||
try {
|
||||
const signedUrl = await this.getSignedUrl(
|
||||
fileName,
|
||||
expiresInSeconds,
|
||||
);
|
||||
resolve({ signedUrl });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
.on("finish", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
@@ -709,6 +738,25 @@ class GoogleCloudStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadWithSignedUrl({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
}: UploadWithSignedUrl): Promise<{ signedUrl: string }> {
|
||||
try {
|
||||
await this.uploadFile({ fileName, data, fileType });
|
||||
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to upload file to Google Cloud Storage ${fileName}`,
|
||||
err,
|
||||
);
|
||||
throw new Error("Failed to upload to Google Cloud Storage");
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadJson(
|
||||
path: string,
|
||||
body: Record<string, unknown>[],
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
await mailer.sendMail({
|
||||
to,
|
||||
from: `Langfuse <${env.EMAIL_FROM_ADDRESS}>`,
|
||||
subject: `${inviterName} invited you to join "${orgName}" organization on Langfuse`,
|
||||
subject: `${inviterName} invited you to join the "${orgName}" organization on Langfuse`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -322,7 +322,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
t.session_id as session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
|
||||
@@ -32,6 +32,18 @@ export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
|
||||
clickhouseTableName: "dataset_run_items_rmt",
|
||||
clickhouseSelect: 'dri."dataset_id"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores (numeric)",
|
||||
uiTableId: "agg_scores_avg",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: "sa.scores_avg",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores (categorical)",
|
||||
uiTableId: "agg_score_categories",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: "sa.score_categories",
|
||||
},
|
||||
];
|
||||
|
||||
export const mapDatasetRunItemFilterColumn = (
|
||||
|
||||
@@ -4,18 +4,9 @@ import {
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
convertTraceToTraceNull,
|
||||
} from "../repositories/definitions";
|
||||
import { env } from "../../env";
|
||||
|
||||
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
|
||||
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
|
||||
await clickhouseClient().insert({
|
||||
table: "traces_null",
|
||||
format: "JSONEachRow",
|
||||
values: trace.map(convertTraceToTraceNull),
|
||||
});
|
||||
}
|
||||
return await clickhouseClient().insert({
|
||||
table: "traces",
|
||||
format: "JSONEachRow",
|
||||
|
||||
@@ -44,8 +44,16 @@ const blockedNetworks = BLOCKED_CIDRS.map((cidr) => {
|
||||
/**
|
||||
* Check if an IP address is blocked based on CIDR ranges
|
||||
*/
|
||||
export function isIPBlocked(ipString: string): boolean {
|
||||
export function isIPBlocked(
|
||||
ipString: string,
|
||||
whitelistedIPs: string[],
|
||||
): boolean {
|
||||
try {
|
||||
// Check if IP is in whitelist first
|
||||
if (whitelistedIPs.includes(ipString.toLowerCase().trim())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ip = ipaddr.parse(ipString);
|
||||
|
||||
for (const { network, mask, kind } of blockedNetworks) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dns from "node:dns/promises";
|
||||
import { URL } from "node:url";
|
||||
import { isIPBlocked, isIPAddress, isHostnameBlocked } from "./ipBlocking";
|
||||
import { env } from "../../env";
|
||||
|
||||
export async function resolveHost(hostname: string): Promise<string[]> {
|
||||
// Returns every A + AAAA address
|
||||
@@ -64,7 +65,7 @@ export async function validateWebhookURL(urlString: string): Promise<void> {
|
||||
|
||||
// Step 4: Check for IP address literals in hostname
|
||||
if (isIPAddress(hostname)) {
|
||||
if (isIPBlocked(hostname)) {
|
||||
if (isIPBlocked(hostname, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) {
|
||||
throw new Error(`Blocked IP address detected: ${hostname}`);
|
||||
}
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export async function validateWebhookURL(urlString: string): Promise<void> {
|
||||
// Step 5: DNS resolution and validation
|
||||
const ips = await resolveHost(hostname);
|
||||
for (const ip of ips) {
|
||||
if (isIPBlocked(ip)) {
|
||||
if (isIPBlocked(ip, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) {
|
||||
throw new Error(`Blocked IP address detected: ${ip}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type ColumnDefinition, type MultiValueOption } from "..";
|
||||
import { formatColumnOptions } from "./typeHelpers";
|
||||
|
||||
export const datasetRunItemsTableCols: ColumnDefinition[] = [
|
||||
{
|
||||
name: "Scores (numeric)",
|
||||
id: "agg_scores_avg",
|
||||
type: "numberObject",
|
||||
internal: "agg_scores_avg",
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Scores (categorical)",
|
||||
id: "agg_score_categories",
|
||||
type: "categoryOptions",
|
||||
internal: "agg_score_categories",
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type DatasetRunItemsOptions = {
|
||||
agg_scores_avg?: Array<string>;
|
||||
agg_score_categories?: Array<MultiValueOption>;
|
||||
};
|
||||
|
||||
export function datasetRunItemsTableColsWithOptions(
|
||||
options?: DatasetRunItemsOptions,
|
||||
cols: ColumnDefinition[] = datasetRunItemsTableCols,
|
||||
): ColumnDefinition[] {
|
||||
return cols.map((col) => {
|
||||
if (col.id === "agg_scores_avg") {
|
||||
return formatColumnOptions(col, options?.agg_scores_avg ?? []);
|
||||
}
|
||||
if (col.id === "agg_score_categories") {
|
||||
return formatColumnOptions(col, options?.agg_score_categories ?? []);
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from "./promptsTable";
|
||||
export * from "./datasetItemsTable";
|
||||
export * from "./mapDatasetRunsTable";
|
||||
export * from "./datasetRunsTable";
|
||||
export * from "./datasetRunItemsTable";
|
||||
|
||||
@@ -39,4 +39,5 @@ export type TableName =
|
||||
| "eval_configs"
|
||||
| "dataset_items"
|
||||
| "job_executions"
|
||||
| "dataset_runs";
|
||||
| "dataset_runs"
|
||||
| "dataset_run_items_by_run";
|
||||
|
||||
Generated
+1288
-1174
File diff suppressed because it is too large
Load Diff
+36
-10
@@ -1,15 +1,26 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": [".env"],
|
||||
"globalDependencies": [
|
||||
".env"
|
||||
],
|
||||
"envMode": "loose",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["db:generate", "^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
],
|
||||
"cache": true
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["^start"]
|
||||
"dependsOn": [
|
||||
"^start"
|
||||
]
|
||||
},
|
||||
"db:migrate": {
|
||||
"cache": false
|
||||
@@ -22,29 +33,44 @@
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate"]
|
||||
"dependsOn": [
|
||||
"db:generate"
|
||||
]
|
||||
},
|
||||
"dev:worker": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate"]
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
]
|
||||
},
|
||||
"dev:web": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate"]
|
||||
"dependsOn": [
|
||||
"db:generate"
|
||||
]
|
||||
},
|
||||
"db:generate": {
|
||||
"cache": false,
|
||||
"dependsOn": ["^db:generate"]
|
||||
"dependsOn": [
|
||||
"^db:generate"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"cache": true,
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^test", "db:generate"],
|
||||
"dependsOn": [
|
||||
"^test",
|
||||
"db:generate"
|
||||
],
|
||||
"cache": true
|
||||
},
|
||||
"worker#dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:24-alpine AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
|
||||
RUN npm install turbo@^2.5.6 --global
|
||||
RUN npm install turbo@^2.5.8 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
@@ -125,7 +125,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
|
||||
fi
|
||||
|
||||
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
|
||||
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
|
||||
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.19.0/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
|
||||
mv migrate /usr/bin/migrate
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
|
||||
@@ -46,9 +46,26 @@ Sentry.init({
|
||||
// For example, a tracesSampleRate of 0.5 and profilesSampleRate of 0.5 would
|
||||
// result in 25% of transactions being profiled (0.5*0.5=0.25)
|
||||
profilesSampleRate: 0.5,
|
||||
// ...
|
||||
|
||||
// Filter out browser extension errors
|
||||
// see: https://docs.sentry.io/platforms/javascript/configuration/filtering/#using-allowurls-and-denyurls
|
||||
denyUrls: [
|
||||
// Chrome extensions
|
||||
/chrome-extension:\/\//i,
|
||||
// Firefox extensions
|
||||
/moz-extension:\/\//i,
|
||||
// Safari extensions
|
||||
/safari-extension:\/\//i,
|
||||
// Edge extensions
|
||||
/ms-browser-extension:\/\//i,
|
||||
// Generic browser extension patterns
|
||||
/app:\/\/\/scripts\//i,
|
||||
],
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
|
||||
// Export router transition start hook for navigation instrumentation
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
+11
-7
@@ -68,15 +68,17 @@ const nextConfig = {
|
||||
// this is an ugly hack to get turbopack to work with react-resizable, used in the
|
||||
// web/src/features/widgets/components/DashboardGrid.tsx file. This **only** affects
|
||||
// the dev server. The CSS is included in the non-turbopack based prod build anyways.
|
||||
// Also not needed for the non-turbopack based dev server.
|
||||
"react-resizable/css/styles.css":
|
||||
"../node_modules/.pnpm/react-resizable@3.0.5_react-dom@19.1.1_react@19.1.1__react@19.1.1/node_modules/react-resizable/css/styles.css",
|
||||
},
|
||||
},
|
||||
// TODO: enable with new next version! 15.6
|
||||
// see: https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPersistentCaching
|
||||
// experimental:{
|
||||
// turbopackPersistentCaching: true,
|
||||
// },
|
||||
experimental: {
|
||||
browserDebugInfoInTerminal: true, // Logs browser logs to terminal
|
||||
// TODO: enable with new next version! 15.6
|
||||
// see: https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPersistentCaching
|
||||
// turbopackPersistentCaching: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
|
||||
@@ -228,7 +230,9 @@ const sentryConfig = withSentryConfig(nextConfig, {
|
||||
// tunnelRoute: "/api/monitoring-tunnel",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
sourcemaps: {
|
||||
disable: true,
|
||||
},
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
@@ -243,7 +247,7 @@ const sentryConfig = withSentryConfig(nextConfig, {
|
||||
// Enable bundle analyzer in analyze mode, otherwise use standard config
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
openAnalyzer: false, // Open analyzer in browser
|
||||
openAnalyzer: true, // Open analyzer in browser
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(sentryConfig);
|
||||
|
||||
+11
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.108.0",
|
||||
"version": "3.114.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
|
||||
"dev": "dotenv -e ../.env -- next dev",
|
||||
"dev": "dotenv -e ../.env -- next dev --turbopack",
|
||||
"dev:http": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
|
||||
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
|
||||
"lint:fix": "dotenv -e ../.env -- next lint --fix",
|
||||
@@ -19,7 +19,8 @@
|
||||
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --verbose --runInBand --detectOpenHandles --selectProjects client",
|
||||
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env.test -e ../.env -- playwright test --reporter=line",
|
||||
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
|
||||
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server",
|
||||
"build-trace": "dotenv -e ../.env -- next internal trace .next/trace-turbopack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
@@ -84,7 +85,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@sentry/nextjs": "^8.52.0",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.85.1",
|
||||
@@ -117,15 +118,17 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.3.28",
|
||||
"langchain": "^0.3.30",
|
||||
"langfuse": "3.38.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"nanoid": "^3.3.8",
|
||||
"next": "15.5.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-query-params": "^5.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-node": "^4.3.1",
|
||||
"posthog-js": "^1.266.0",
|
||||
"posthog-node": "^5.8.4",
|
||||
"prexit": "^2.2.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prisma": "^6.10.1",
|
||||
@@ -149,7 +152,7 @@
|
||||
"remark-rehype": "^11.1.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^17.4.0",
|
||||
"stripe": "^18.5.0",
|
||||
"superjson": "2.2.2",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -560,6 +560,146 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
|
||||
/api/public/integrations/blob-storage:
|
||||
get:
|
||||
description: >-
|
||||
Get all blob storage integrations for the organization (requires
|
||||
organization-scoped API key)
|
||||
operationId: blobStorageIntegrations_getBlobStorageIntegrations
|
||||
tags:
|
||||
- BlobStorageIntegrations
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update a blob storage integration for a specific project
|
||||
(requires organization-scoped API key). The configuration is validated
|
||||
by performing a test upload to the bucket.
|
||||
operationId: blobStorageIntegrations_upsertBlobStorageIntegration
|
||||
tags:
|
||||
- BlobStorageIntegrations
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateBlobStorageIntegrationRequest'
|
||||
/api/public/integrations/blob-storage/{id}:
|
||||
delete:
|
||||
description: >-
|
||||
Delete a blob storage integration by ID (requires organization-scoped
|
||||
API key)
|
||||
operationId: blobStorageIntegrations_deleteBlobStorageIntegration
|
||||
tags:
|
||||
- BlobStorageIntegrations
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationDeletionResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/comments:
|
||||
post:
|
||||
description: >-
|
||||
@@ -1412,11 +1552,11 @@ paths:
|
||||
/api/public/ingestion:
|
||||
post:
|
||||
description: >-
|
||||
Batched ingestion for Langfuse Tracing.
|
||||
**Legacy endpoint for batch ingestion for Langfuse Observability.**
|
||||
|
||||
If you want to use tracing via the API, such as to build your own
|
||||
Langfuse client implementation, this is the only API route you need to
|
||||
implement.
|
||||
|
||||
-> Please use the OpenTelemetry endpoint (`/api/public/otel`). Learn
|
||||
more: https://langfuse.com/integrations/native/opentelemetry
|
||||
|
||||
|
||||
Within each batch, there can be multiple events.
|
||||
@@ -1440,7 +1580,7 @@ paths:
|
||||
Notes:
|
||||
|
||||
- Introduction to data model:
|
||||
https://langfuse.com/docs/tracing-data-model
|
||||
https://langfuse.com/docs/observability/data-model
|
||||
|
||||
- Batch sizes are limited to 3.5 MB in total. You need to adjust the
|
||||
number of events per batch accordingly.
|
||||
@@ -3773,6 +3913,58 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
patch:
|
||||
description: Update a score config
|
||||
operationId: scoreConfigs_update
|
||||
tags:
|
||||
- ScoreConfigs
|
||||
parameters:
|
||||
- name: configId
|
||||
in: path
|
||||
description: The unique langfuse identifier of a score config
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScoreConfig'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateScoreConfigRequest'
|
||||
/api/public/v2/scores:
|
||||
get:
|
||||
description: Get a list of scores (supports both trace and session scores)
|
||||
@@ -3877,6 +4069,13 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: sessionId
|
||||
in: query
|
||||
description: Retrieve only scores with a specific sessionId.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: queueId
|
||||
in: query
|
||||
description: Retrieve only scores with a specific annotation queueId.
|
||||
@@ -4413,9 +4612,11 @@ paths:
|
||||
in: query
|
||||
description: >-
|
||||
Comma-separated list of fields to include in the response. Available
|
||||
field groups are 'core' (always included), 'io' (input, output,
|
||||
metadata), 'scores', 'observations', 'metrics'. If not provided, all
|
||||
fields are included. Example: 'core,scores,metrics'
|
||||
field groups: 'core' (always included), 'io' (input, output,
|
||||
metadata), 'scores', 'observations', 'metrics'. If not specified,
|
||||
all fields are returned. Example: 'core,scores,metrics'. Note:
|
||||
Excluded 'observations' or 'scores' fields return empty arrays;
|
||||
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -4686,6 +4887,178 @@ components:
|
||||
- userId
|
||||
- queueId
|
||||
- projectId
|
||||
BlobStorageIntegrationType:
|
||||
title: BlobStorageIntegrationType
|
||||
type: string
|
||||
enum:
|
||||
- S3
|
||||
- S3_COMPATIBLE
|
||||
- AZURE_BLOB_STORAGE
|
||||
BlobStorageIntegrationFileType:
|
||||
title: BlobStorageIntegrationFileType
|
||||
type: string
|
||||
enum:
|
||||
- JSON
|
||||
- CSV
|
||||
- JSONL
|
||||
BlobStorageExportMode:
|
||||
title: BlobStorageExportMode
|
||||
type: string
|
||||
enum:
|
||||
- FULL_HISTORY
|
||||
- FROM_TODAY
|
||||
- FROM_CUSTOM_DATE
|
||||
BlobStorageExportFrequency:
|
||||
title: BlobStorageExportFrequency
|
||||
type: string
|
||||
enum:
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
CreateBlobStorageIntegrationRequest:
|
||||
title: CreateBlobStorageIntegrationRequest
|
||||
type: object
|
||||
properties:
|
||||
projectId:
|
||||
type: string
|
||||
description: ID of the project in which to configure the blob storage integration
|
||||
type:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationType'
|
||||
bucketName:
|
||||
type: string
|
||||
description: Name of the storage bucket
|
||||
endpoint:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom endpoint URL (required for S3_COMPATIBLE type)
|
||||
region:
|
||||
type: string
|
||||
description: Storage region
|
||||
accessKeyId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Access key ID for authentication
|
||||
secretAccessKey:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Secret access key for authentication (will be encrypted when stored)
|
||||
prefix:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Path prefix for exported files (must end with forward slash if
|
||||
provided)
|
||||
exportFrequency:
|
||||
$ref: '#/components/schemas/BlobStorageExportFrequency'
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the integration is active
|
||||
forcePathStyle:
|
||||
type: boolean
|
||||
description: Use path-style URLs for S3 requests
|
||||
fileType:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
|
||||
exportMode:
|
||||
$ref: '#/components/schemas/BlobStorageExportMode'
|
||||
exportStartDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: >-
|
||||
Custom start date for exports (required when exportMode is
|
||||
FROM_CUSTOM_DATE)
|
||||
required:
|
||||
- projectId
|
||||
- type
|
||||
- bucketName
|
||||
- region
|
||||
- exportFrequency
|
||||
- enabled
|
||||
- forcePathStyle
|
||||
- fileType
|
||||
- exportMode
|
||||
BlobStorageIntegrationResponse:
|
||||
title: BlobStorageIntegrationResponse
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationType'
|
||||
bucketName:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
nullable: true
|
||||
region:
|
||||
type: string
|
||||
accessKeyId:
|
||||
type: string
|
||||
nullable: true
|
||||
prefix:
|
||||
type: string
|
||||
exportFrequency:
|
||||
$ref: '#/components/schemas/BlobStorageExportFrequency'
|
||||
enabled:
|
||||
type: boolean
|
||||
forcePathStyle:
|
||||
type: boolean
|
||||
fileType:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationFileType'
|
||||
exportMode:
|
||||
$ref: '#/components/schemas/BlobStorageExportMode'
|
||||
exportStartDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
nextSyncAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
lastSyncAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- projectId
|
||||
- type
|
||||
- bucketName
|
||||
- region
|
||||
- prefix
|
||||
- exportFrequency
|
||||
- enabled
|
||||
- forcePathStyle
|
||||
- fileType
|
||||
- exportMode
|
||||
- createdAt
|
||||
- updatedAt
|
||||
BlobStorageIntegrationsResponse:
|
||||
title: BlobStorageIntegrationsResponse
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationResponse'
|
||||
required:
|
||||
- data
|
||||
BlobStorageIntegrationDeletionResponse:
|
||||
title: BlobStorageIntegrationDeletionResponse
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
CreateCommentRequest:
|
||||
title: CreateCommentRequest
|
||||
type: object
|
||||
@@ -7821,6 +8194,48 @@ components:
|
||||
required:
|
||||
- name
|
||||
- dataType
|
||||
UpdateScoreConfigRequest:
|
||||
title: UpdateScoreConfigRequest
|
||||
type: object
|
||||
properties:
|
||||
isArchived:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: The status of the score config showing if it is archived or not
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the score config
|
||||
categories:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigCategory'
|
||||
nullable: true
|
||||
description: >-
|
||||
Configure custom categories for categorical scores. Pass a list of
|
||||
objects with `label` and `value` properties. Categories are
|
||||
autogenerated for boolean configs and cannot be passed
|
||||
minValue:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: >-
|
||||
Configure a minimum value for numerical scores. If not set, the
|
||||
minimum value defaults to -∞
|
||||
maxValue:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: >-
|
||||
Configure a maximum value for numerical scores. If not set, the
|
||||
maximum value defaults to +∞
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Description is shown across the Langfuse UI and can be used to e.g.
|
||||
explain the config categories in detail, why a numeric range was
|
||||
set, or provide additional context on config name or usage
|
||||
GetScoresResponseTraceData:
|
||||
title: GetScoresResponseTraceData
|
||||
type: object
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
makeAPICall,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { z } from "zod/v4";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createAndAddApiKeysToDb,
|
||||
createBasicAuthHeader,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// Schemas based on Fern schema definition
|
||||
const BlobStorageIntegrationResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
type: z.enum(["S3", "S3_COMPATIBLE", "AZURE_BLOB_STORAGE"]),
|
||||
bucketName: z.string(),
|
||||
endpoint: z.string().nullable(),
|
||||
region: z.string(),
|
||||
accessKeyId: z.string().nullable(),
|
||||
prefix: z.string(),
|
||||
exportFrequency: z.enum(["hourly", "daily", "weekly"]),
|
||||
enabled: z.boolean(),
|
||||
forcePathStyle: z.boolean(),
|
||||
fileType: z.enum(["JSON", "CSV", "JSONL"]),
|
||||
exportMode: z.enum(["FULL_HISTORY", "FROM_TODAY", "FROM_CUSTOM_DATE"]),
|
||||
exportStartDate: z.coerce.date().nullable(),
|
||||
nextSyncAt: z.coerce.date().nullable(),
|
||||
lastSyncAt: z.coerce.date().nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
const BlobStorageIntegrationsResponseSchema = z.object({
|
||||
data: z.array(BlobStorageIntegrationResponseSchema),
|
||||
});
|
||||
|
||||
const BlobStorageIntegrationDeletionResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// Valid blob storage integration request payload
|
||||
const validBlobStorageConfig = {
|
||||
projectId: "",
|
||||
type: "S3" as const,
|
||||
bucketName: "test-bucket",
|
||||
endpoint: null,
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA123456789",
|
||||
secretAccessKey: "secret123456789",
|
||||
prefix: "langfuse-exports/",
|
||||
exportFrequency: "daily" as const,
|
||||
enabled: true,
|
||||
forcePathStyle: false,
|
||||
fileType: "JSONL" as const,
|
||||
exportMode: "FULL_HISTORY" as const,
|
||||
exportStartDate: null,
|
||||
};
|
||||
|
||||
describe("Blob Storage Integrations API", () => {
|
||||
// Test data
|
||||
let testOrgId: string;
|
||||
let testProject1Id: string;
|
||||
let testProject2Id: string;
|
||||
let testApiKey: string;
|
||||
let testApiSecretKey: string;
|
||||
let otherOrgId: string;
|
||||
let otherProjectId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test organization
|
||||
const testOrg = await prisma.organization.create({
|
||||
data: {
|
||||
name: `Blob Storage Test Org ${randomUUID().substring(0, 8)}`,
|
||||
cloudConfig: { plan: "Team" },
|
||||
},
|
||||
});
|
||||
testOrgId = testOrg.id;
|
||||
|
||||
// Create test projects
|
||||
const testProject1 = await prisma.project.create({
|
||||
data: {
|
||||
name: `Blob Storage Test Project 1 ${randomUUID().substring(0, 8)}`,
|
||||
orgId: testOrgId,
|
||||
},
|
||||
});
|
||||
testProject1Id = testProject1.id;
|
||||
|
||||
const testProject2 = await prisma.project.create({
|
||||
data: {
|
||||
name: `Blob Storage Test Project 2 ${randomUUID().substring(0, 8)}`,
|
||||
orgId: testOrgId,
|
||||
},
|
||||
});
|
||||
testProject2Id = testProject2.id;
|
||||
|
||||
// Create organization API key
|
||||
const orgApiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: testOrgId,
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Blob Storage API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-blob-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-blob-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
testApiKey = orgApiKey.publicKey;
|
||||
testApiSecretKey = orgApiKey.secretKey;
|
||||
|
||||
// Create another organization for cross-org tests
|
||||
const otherOrg = await prisma.organization.create({
|
||||
data: {
|
||||
name: `Other Blob Storage Org ${randomUUID().substring(0, 8)}`,
|
||||
cloudConfig: { plan: "Team" },
|
||||
},
|
||||
});
|
||||
otherOrgId = otherOrg.id;
|
||||
|
||||
const otherProject = await prisma.project.create({
|
||||
data: {
|
||||
name: `Other Blob Storage Project ${randomUUID().substring(0, 8)}`,
|
||||
orgId: otherOrgId,
|
||||
},
|
||||
});
|
||||
otherProjectId = otherProject.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await prisma.organization.delete({
|
||||
where: { id: testOrgId },
|
||||
});
|
||||
await prisma.organization.delete({
|
||||
where: { id: otherOrgId },
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/public/integrations/blob-storage", () => {
|
||||
let testIntegrationId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a test blob storage integration
|
||||
const integration = await prisma.blobStorageIntegration.create({
|
||||
data: {
|
||||
projectId: testProject1Id,
|
||||
type: "S3",
|
||||
bucketName: "test-bucket",
|
||||
region: "us-east-1",
|
||||
accessKeyId: "test-access-key",
|
||||
secretAccessKey: "encrypted-secret",
|
||||
prefix: "langfuse-exports/",
|
||||
exportFrequency: "daily",
|
||||
enabled: true,
|
||||
forcePathStyle: false,
|
||||
fileType: "JSONL",
|
||||
exportMode: "FULL_HISTORY",
|
||||
},
|
||||
});
|
||||
testIntegrationId = integration.projectId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test integration
|
||||
await prisma.blobStorageIntegration.deleteMany({
|
||||
where: { projectId: testProject1Id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all blob storage integrations for the organization", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationsResponseSchema,
|
||||
"GET",
|
||||
"/api/public/integrations/blob-storage",
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
|
||||
const integration = response.body.data.find(
|
||||
(i) => i.id === testIntegrationId,
|
||||
);
|
||||
expect(integration).toBeDefined();
|
||||
expect(integration?.projectId).toBe(testProject1Id);
|
||||
expect(integration?.type).toBe("S3");
|
||||
expect(integration?.bucketName).toBe("test-bucket");
|
||||
expect(integration?.accessKeyId).toBe("test-access-key");
|
||||
// Verify that secretAccessKey is not returned
|
||||
expect(integration).not.toHaveProperty("secretAccessKey");
|
||||
});
|
||||
|
||||
it("should return 401 with invalid API key", async () => {
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
"/api/public/integrations/blob-storage",
|
||||
undefined,
|
||||
createBasicAuthHeader("invalid-key", "invalid-secret"),
|
||||
);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 403 with project-scoped API key", async () => {
|
||||
// Create project API key
|
||||
const projectApiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: testProject1Id,
|
||||
scope: "PROJECT",
|
||||
note: "Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-proj-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-proj-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
"/api/public/integrations/blob-storage",
|
||||
undefined,
|
||||
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
|
||||
);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.body.message).toContain(
|
||||
"Organization-scoped API key required",
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/public/integrations/blob-storage", () => {
|
||||
afterEach(async () => {
|
||||
// Clean up any created integrations
|
||||
await prisma.blobStorageIntegration.deleteMany({
|
||||
where: {
|
||||
projectId: {
|
||||
in: [testProject1Id, testProject2Id],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new blob storage integration", async () => {
|
||||
const requestBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
};
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationResponseSchema,
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
requestBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.projectId).toBe(testProject1Id);
|
||||
expect(response.body.type).toBe("S3");
|
||||
expect(response.body.bucketName).toBe("test-bucket");
|
||||
expect(response.body.enabled).toBe(true);
|
||||
// Verify secretAccessKey is not returned
|
||||
expect(response.body).not.toHaveProperty("secretAccessKey");
|
||||
|
||||
// Verify it was saved to database
|
||||
const savedIntegration = await prisma.blobStorageIntegration.findUnique({
|
||||
where: { projectId: testProject1Id },
|
||||
});
|
||||
expect(savedIntegration).toBeDefined();
|
||||
expect(savedIntegration?.bucketName).toBe("test-bucket");
|
||||
});
|
||||
|
||||
it("should update an existing blob storage integration", async () => {
|
||||
// Create initial integration
|
||||
await prisma.blobStorageIntegration.create({
|
||||
data: {
|
||||
projectId: testProject1Id,
|
||||
type: "S3",
|
||||
bucketName: "old-bucket",
|
||||
region: "us-west-1",
|
||||
accessKeyId: "old-key",
|
||||
secretAccessKey: "old-secret",
|
||||
prefix: "old-prefix/",
|
||||
exportFrequency: "hourly",
|
||||
enabled: false,
|
||||
forcePathStyle: true,
|
||||
fileType: "JSON",
|
||||
exportMode: "FROM_TODAY",
|
||||
},
|
||||
});
|
||||
|
||||
const updateBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
bucketName: "updated-bucket",
|
||||
enabled: true,
|
||||
exportFrequency: "weekly" as const,
|
||||
};
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationResponseSchema,
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
updateBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.bucketName).toBe("updated-bucket");
|
||||
expect(response.body.enabled).toBe(true);
|
||||
expect(response.body.exportFrequency).toBe("weekly");
|
||||
});
|
||||
|
||||
it("should validate required fields", async () => {
|
||||
const invalidBody = {
|
||||
projectId: testProject1Id,
|
||||
type: "S3",
|
||||
// Missing bucketName
|
||||
region: "us-east-1",
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
invalidBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.message).toContain("Invalid request data");
|
||||
});
|
||||
|
||||
it("should validate enum values", async () => {
|
||||
const invalidBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
type: "INVALID_TYPE",
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
invalidBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.message).toContain("Invalid request data");
|
||||
});
|
||||
|
||||
it("should validate prefix format", async () => {
|
||||
const invalidBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
prefix: "invalid-prefix", // Should end with /
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
invalidBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent project", async () => {
|
||||
const nonExistentProjectId = randomUUID();
|
||||
const requestBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: nonExistentProjectId,
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
requestBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.body.message).toContain("Project not found");
|
||||
});
|
||||
|
||||
it("should return 404 for project from different organization", async () => {
|
||||
const requestBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: otherProjectId,
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
requestBody,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.body.message).toContain("Project not found");
|
||||
});
|
||||
|
||||
it("should return 401 with invalid API key", async () => {
|
||||
const requestBody = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
requestBody,
|
||||
createBasicAuthHeader("invalid-key", "invalid-secret"),
|
||||
);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should handle different blob storage types", async () => {
|
||||
const azureConfig = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
type: "AZURE_BLOB_STORAGE" as const,
|
||||
endpoint: "https://myaccount.blob.core.windows.net",
|
||||
};
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationResponseSchema,
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
azureConfig,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.type).toBe("AZURE_BLOB_STORAGE");
|
||||
expect(response.body.endpoint).toBe(
|
||||
"https://myaccount.blob.core.windows.net",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle export modes with dates", async () => {
|
||||
const customDateConfig = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
exportMode: "FROM_CUSTOM_DATE" as const,
|
||||
exportStartDate: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationResponseSchema,
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
customDateConfig,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.exportMode).toBe("FROM_CUSTOM_DATE");
|
||||
expect(response.body.exportStartDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/public/integrations/blob-storage/{id}", () => {
|
||||
let testIntegrationId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test integration
|
||||
const integration = await prisma.blobStorageIntegration.create({
|
||||
data: {
|
||||
projectId: testProject1Id,
|
||||
type: "S3",
|
||||
bucketName: "test-bucket",
|
||||
region: "us-east-1",
|
||||
accessKeyId: "test-key",
|
||||
secretAccessKey: "test-secret",
|
||||
prefix: "test/",
|
||||
exportFrequency: "daily",
|
||||
enabled: true,
|
||||
forcePathStyle: false,
|
||||
fileType: "JSONL",
|
||||
exportMode: "FULL_HISTORY",
|
||||
},
|
||||
});
|
||||
testIntegrationId = integration.projectId; // Based on current implementation, ID is projectId
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await prisma.blobStorageIntegration.deleteMany({
|
||||
where: { projectId: testProject1Id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete a blob storage integration", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
BlobStorageIntegrationDeletionResponseSchema,
|
||||
"DELETE",
|
||||
`/api/public/integrations/blob-storage/${testIntegrationId}`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBeDefined();
|
||||
|
||||
// Verify it was deleted from database
|
||||
const deletedIntegration = await prisma.blobStorageIntegration.findUnique(
|
||||
{
|
||||
where: { projectId: testIntegrationId },
|
||||
},
|
||||
);
|
||||
expect(deletedIntegration).toBeNull();
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent integration", async () => {
|
||||
const nonExistentId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/integrations/blob-storage/${nonExistentId}`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 404 for integration from different organization", async () => {
|
||||
// Create integration in other org
|
||||
const otherOrgIntegration = await prisma.blobStorageIntegration.create({
|
||||
data: {
|
||||
projectId: otherProjectId,
|
||||
type: "S3",
|
||||
bucketName: "other-bucket",
|
||||
region: "us-east-1",
|
||||
accessKeyId: "other-key",
|
||||
secretAccessKey: "other-secret",
|
||||
prefix: "other/",
|
||||
exportFrequency: "daily",
|
||||
enabled: true,
|
||||
forcePathStyle: false,
|
||||
fileType: "JSONL",
|
||||
exportMode: "FULL_HISTORY",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/integrations/blob-storage/${otherOrgIntegration.projectId}`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(404);
|
||||
|
||||
// Check config still exists
|
||||
const integrations = await prisma.blobStorageIntegration.findMany({
|
||||
where: { projectId: otherProjectId },
|
||||
});
|
||||
expect(integrations).toHaveLength(1);
|
||||
|
||||
// Clean up
|
||||
await prisma.blobStorageIntegration.delete({
|
||||
where: { projectId: otherProjectId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 401 with invalid API key", async () => {
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/integrations/blob-storage/${testIntegrationId}`,
|
||||
undefined,
|
||||
createBasicAuthHeader("invalid-key", "invalid-secret"),
|
||||
);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 403 with project-scoped API key", async () => {
|
||||
const projectApiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: testProject1Id,
|
||||
scope: "PROJECT",
|
||||
note: "Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-proj-del-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/integrations/blob-storage/${testIntegrationId}`,
|
||||
undefined,
|
||||
createBasicAuthHeader(projectApiKey.publicKey, projectApiKey.secretKey),
|
||||
);
|
||||
expect(result.status).toBe(403);
|
||||
|
||||
// Clean up
|
||||
await prisma.apiKey.delete({ where: { id: projectApiKey.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Method validation", () => {
|
||||
it("should return 405 for unsupported methods", async () => {
|
||||
const result = await makeAPICall(
|
||||
"PATCH",
|
||||
"/api/public/integrations/blob-storage",
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(405);
|
||||
expect(result.body.message).toContain("Method not allowed");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
ClickHouseResourceError,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { fail } from "assert";
|
||||
|
||||
describe("ClickHouse Resource Error Handling", () => {
|
||||
describe("queryClickhouse", () => {
|
||||
describe("Error transformation with throwIf", () => {
|
||||
// It is enough to test different block sizes on one error type only
|
||||
[1, 10_000].forEach((blockSize) => {
|
||||
it(`should transform OOM errors to ClickHouseResourceError; block size: ${blockSize}`, async () => {
|
||||
let res = Array<any>();
|
||||
try {
|
||||
res = await queryClickhouse<any>({
|
||||
query: `SELECT throwIf(number >= 2, 'memory limit exceeded: would use 10.23 GiB') AS v FROM system.numbers LIMIT 2000`,
|
||||
clickhouseSettings: { max_block_size: `${blockSize}` },
|
||||
});
|
||||
fail(
|
||||
"Should have thrown an error, observed instead " +
|
||||
JSON.stringify(res),
|
||||
);
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("MEMORY_LIMIT");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform OvercommitTracker errors", async () => {
|
||||
try {
|
||||
await queryClickhouse({
|
||||
query: `SELECT throwIf(true, 'OvercommitTracker decision: Query was selected to stop by OvercommitTracker')`,
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("OVERCOMMIT");
|
||||
}
|
||||
});
|
||||
|
||||
it("should transform timeout errors", async () => {
|
||||
try {
|
||||
await queryClickhouse({
|
||||
query: `SELECT throwIf(true, 'Timeout exceeded while reading from socket')`,
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(ClickHouseResourceError);
|
||||
expect(error.errorType).toBe("TIMEOUT");
|
||||
}
|
||||
});
|
||||
|
||||
it("should NOT transform regular SQL errors", async () => {
|
||||
await expect(
|
||||
queryClickhouse({
|
||||
query: `SELECT * FROM non_existent_table_xyz123`,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
try {
|
||||
await queryClickhouse({
|
||||
query: `SELECT * FROM non_existent_table_xyz123`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
expect(error).not.toBeInstanceOf(ClickHouseResourceError);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass through successful queries", async () => {
|
||||
const result = await queryClickhouse<{ test_value: Number }>({
|
||||
query: "SELECT 1 as test_value",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("test_value");
|
||||
expect(result[0].test_value).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryClickhouseStream", () => {
|
||||
// We don't need to test all error types here, just one is enough
|
||||
// to verify streaming works with throwIf and different block sizes.
|
||||
[1, 10_000].forEach((blockSize) => {
|
||||
it(`should transform errors during streaming; block size ${blockSize}`, async () => {
|
||||
const generator = queryClickhouseStream({
|
||||
query: `SELECT throwIf(number = 2, 'memory limit exceeded: would use 10.23 GiB') as V FROM numbers(10)`,
|
||||
clickhouseSettings: { max_block_size: `${blockSize}` },
|
||||
});
|
||||
|
||||
let fullResponse = [];
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const item of generator) {
|
||||
fullResponse.push(item);
|
||||
}
|
||||
fail(
|
||||
"Should have thrown an error, observed instead " +
|
||||
JSON.stringify(fullResponse),
|
||||
);
|
||||
})(),
|
||||
).rejects.toThrow(ClickHouseResourceError);
|
||||
});
|
||||
|
||||
it("should stream successful queries", async () => {
|
||||
const generator = queryClickhouseStream({
|
||||
query: "SELECT number FROM system.numbers LIMIT 3",
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for await (const item of generator) {
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0]).toHaveProperty("number");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error patterns documentation", () => {
|
||||
// Document the actual error patterns we handle
|
||||
const errorPatterns = [
|
||||
{
|
||||
name: "Memory limit exceeded with details",
|
||||
errorMessage:
|
||||
"(total) memory limit exceeded: would use 10.08 GiB (attempt to allocate chunk of 4.49 MiB bytes), current RSS: 1.83 GiB, maximum: 8.40 GiB",
|
||||
shouldBeResourceError: true,
|
||||
errorType: "MEMORY_LIMIT",
|
||||
},
|
||||
{
|
||||
name: "OvercommitTracker error",
|
||||
errorMessage:
|
||||
"OvercommitTracker decision: Query was selected to stop by OvercommitTracker: While executing WaitForAsyncInsert",
|
||||
shouldBeResourceError: true,
|
||||
errorType: "OVERCOMMIT",
|
||||
},
|
||||
{
|
||||
name: "Simple memory error",
|
||||
errorMessage: "memory limit exceeded",
|
||||
shouldBeResourceError: true,
|
||||
errorType: "MEMORY_LIMIT",
|
||||
},
|
||||
{
|
||||
name: "Timeout error",
|
||||
errorMessage: "Timeout exceeded while reading from socket",
|
||||
shouldBeResourceError: true,
|
||||
errorType: "TIMEOUT",
|
||||
},
|
||||
{
|
||||
name: "SQL error (not a resource error)",
|
||||
errorMessage: "Table 'test.non_existent' doesn't exist",
|
||||
shouldBeResourceError: false,
|
||||
errorType: null,
|
||||
},
|
||||
{
|
||||
name: "Network error (retryable but not a resource error)",
|
||||
errorMessage: "socket hang up",
|
||||
shouldBeResourceError: false,
|
||||
errorType: null,
|
||||
},
|
||||
];
|
||||
|
||||
errorPatterns.forEach(
|
||||
({ name, errorMessage, shouldBeResourceError, errorType }) => {
|
||||
it(`should correctly classify "${name}"`, () => {
|
||||
const error = new Error(errorMessage);
|
||||
const wrappedError =
|
||||
ClickHouseResourceError.wrapIfResourceError(error);
|
||||
const isResourceError = ((err: Error) => {
|
||||
if (err instanceof ClickHouseResourceError) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})(wrappedError);
|
||||
|
||||
expect(isResourceError).toBe(shouldBeResourceError);
|
||||
|
||||
const resourceError = wrappedError as ClickHouseResourceError;
|
||||
if (shouldBeResourceError && errorType) {
|
||||
expect(resourceError.errorType).toBe(errorType);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
createTracesCh,
|
||||
createOrgProjectAndApiKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { makeZodVerifiedAPICall } from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
makeAPICall,
|
||||
makeZodVerifiedAPICall,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
DeleteScoreResponseV1,
|
||||
GetScoreResponseV1,
|
||||
@@ -1031,6 +1034,21 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject session ID filtering", async () => {
|
||||
try {
|
||||
await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/scores?sessionId=${sessionId}`,
|
||||
undefined,
|
||||
authentication,
|
||||
);
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain(
|
||||
"API call did not return 200, returned status 400",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -865,6 +865,39 @@ describe("/api/public/v2/scores API Endpoint", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter scores by session ID", async () => {
|
||||
const getScore = await makeZodVerifiedAPICall(
|
||||
GetScoresResponseV2,
|
||||
"GET",
|
||||
`/api/public/v2/scores?sessionId=${sessionId}`,
|
||||
undefined,
|
||||
authentication,
|
||||
);
|
||||
expect(getScore.status).toBe(200);
|
||||
expect(getScore.body.meta).toMatchObject({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalItems: 2,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(getScore.body.data).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: scoreId_6,
|
||||
sessionId: sessionId,
|
||||
name: scoreName,
|
||||
value: 100.5,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: scoreId_7,
|
||||
sessionId: sessionId,
|
||||
name: "session-score-name",
|
||||
value: 100.5,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,17 @@ import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import {
|
||||
GetScoreConfigResponse,
|
||||
GetScoreConfigsResponse,
|
||||
PostScoreConfigResponse,
|
||||
PutScoreConfigResponse,
|
||||
} from "@/src/features/public-api/types/score-configs";
|
||||
import {
|
||||
type ScoreConfig,
|
||||
prisma,
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared/src/db";
|
||||
import {
|
||||
GetScoreConfigResponse,
|
||||
PostScoreConfigResponse,
|
||||
GetScoreConfigsResponse,
|
||||
} from "@langfuse/shared";
|
||||
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
|
||||
const configOne = [
|
||||
@@ -412,4 +413,341 @@ describe("/api/public/score-configs API Endpoint", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/public/score-configs/:configId", () => {
|
||||
it("should successfully archive a score config", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configOne[0].name,
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeZodVerifiedAPICall(
|
||||
PutScoreConfigResponse,
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
isArchived: true,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(patchResponse.body.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail when trying to update with invalid data type mismatch - numeric to categorical", async () => {
|
||||
const foundConfig = await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name, // Numeric config
|
||||
},
|
||||
});
|
||||
const { id: configId } = foundConfig as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
categories: [
|
||||
{ label: "Good", value: 1 },
|
||||
{ label: "Bad", value: 0 },
|
||||
],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining("Invalid input"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail when trying to update boolean with custom categories", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configOne[0].name, // Boolean config
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
categories: [
|
||||
{ label: "Custom Good", value: 1 },
|
||||
{ label: "Custom Bad", value: 0 },
|
||||
],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining("Invalid input"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail when trying to update with invalid numeric range", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name, // Numeric config
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
minValue: 100,
|
||||
maxValue: 50,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"Maximum value must be greater than Minimum value",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail when trying to update with invalid name length", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name,
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
name: "a".repeat(50), // Exceeds 35 character limit
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining("Invalid request"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail when trying to update with empty name", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name,
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
name: "",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining("Invalid request"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail when trying to update non-existent config", async () => {
|
||||
const nonExistentId = "non-existent-config-id";
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${nonExistentId}`,
|
||||
{
|
||||
name: "Updated Name",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(404);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: "Score config not found within authorized project",
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully update only specific fields without affecting others", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name,
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
// Get current config state
|
||||
const beforePatch = await makeZodVerifiedAPICall(
|
||||
GetScoreConfigResponse,
|
||||
"GET",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
// Update only description
|
||||
const patchResponse = await makeZodVerifiedAPICall(
|
||||
PutScoreConfigResponse,
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
description: "Only description updated",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(patchResponse.body.description).toBe("Only description updated");
|
||||
// Verify other fields remain unchanged
|
||||
expect(patchResponse.body.name).toBe(beforePatch.body.name);
|
||||
expect(patchResponse.body.dataType).toBe(beforePatch.body.dataType);
|
||||
expect(patchResponse.body.minValue).toBe(beforePatch.body.minValue);
|
||||
});
|
||||
|
||||
it("should fail when trying to update with empty body", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name,
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeAPICall(
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(400);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
message: expect.stringContaining("Invalid request"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully update a numeric score config", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configTwo[0].name, // "Test Numeric Config"
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeZodVerifiedAPICall(
|
||||
PutScoreConfigResponse,
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
name: "Updated Numeric Config",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
description: "Updated description",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
id: configId,
|
||||
name: "Updated Numeric Config",
|
||||
dataType: "NUMERIC",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
description: "Updated description",
|
||||
});
|
||||
|
||||
// Verify the update persisted
|
||||
const getResponse = await makeZodVerifiedAPICall(
|
||||
GetScoreConfigResponse,
|
||||
"GET",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(getResponse.body.name).toBe("Updated Numeric Config");
|
||||
expect(getResponse.body.minValue).toBe(5);
|
||||
expect(getResponse.body.maxValue).toBe(100);
|
||||
});
|
||||
|
||||
it("should successfully update a categorical score config", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configThree[0].name, // "Test Categorical Config"
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeZodVerifiedAPICall(
|
||||
PutScoreConfigResponse,
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
name: "Updated Categorical Config",
|
||||
categories: [
|
||||
{ label: "Excellent", value: 3 },
|
||||
{ label: "Good", value: 2 },
|
||||
{ label: "Poor", value: 1 },
|
||||
],
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
id: configId,
|
||||
name: "Updated Categorical Config",
|
||||
dataType: "CATEGORICAL",
|
||||
categories: [
|
||||
{ label: "Excellent", value: 3 },
|
||||
{ label: "Good", value: 2 },
|
||||
{ label: "Poor", value: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully update a boolean score config", async () => {
|
||||
const { id: configId } = (await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: configOne[0].name, // "Test Boolean Config"
|
||||
},
|
||||
})) as ScoreConfig;
|
||||
|
||||
const patchResponse = await makeZodVerifiedAPICall(
|
||||
PutScoreConfigResponse,
|
||||
"PATCH",
|
||||
`/api/public/score-configs/${configId}`,
|
||||
{
|
||||
name: "Updated Boolean Config",
|
||||
description: "Updated boolean description",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(patchResponse.body).toMatchObject({
|
||||
id: configId,
|
||||
name: "Updated Boolean Config",
|
||||
dataType: "BOOLEAN",
|
||||
description: "Updated boolean description",
|
||||
categories: [
|
||||
{ label: "True", value: 1 },
|
||||
{ label: "False", value: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -492,6 +492,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw an error if langfuse scope spans have wrong project ID", async () => {
|
||||
const langfuseOtelSpans = [
|
||||
{
|
||||
@@ -1443,6 +1444,55 @@ describe("OTel Resource Span Mapping", () => {
|
||||
expect(retrieverEvent?.body.usageDetails.input).toBe(50);
|
||||
});
|
||||
|
||||
it("should map tool-call spans with empty model-related attributes to span-create (not generation-create)", async () => {
|
||||
const resourceSpan = {
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
...defaultSpanProps,
|
||||
name: "tool-call",
|
||||
attributes: [
|
||||
{ key: "model", value: { stringValue: "" } },
|
||||
{ key: "provided_model_name", value: { stringValue: "" } },
|
||||
{ key: "internal_model_id", value: { stringValue: "" } },
|
||||
{ key: "model_parameters", value: { stringValue: "{}" } },
|
||||
{
|
||||
key: "provided_usage_details",
|
||||
value: { stringValue: "{}" },
|
||||
},
|
||||
{ key: "usage_details", value: { stringValue: "{}" } },
|
||||
{
|
||||
key: "provided_cost_details",
|
||||
value: { stringValue: "{}" },
|
||||
},
|
||||
{ key: "cost_details", value: { stringValue: "{}" } },
|
||||
{ key: "total_cost", value: { stringValue: "" } },
|
||||
{ key: "completion_start_time", value: { stringValue: "" } },
|
||||
{ key: "prompt_id", value: { stringValue: "" } },
|
||||
{ key: "prompt_name", value: { stringValue: "" } },
|
||||
{ key: "prompt_version", value: { stringValue: "" } },
|
||||
{ key: "token_count", value: { stringValue: "" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const langfuseEvents = await convertOtelSpanToIngestionEvent(
|
||||
resourceSpan,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const observationEvent = langfuseEvents.find(
|
||||
(event) => event.type !== "trace-create",
|
||||
);
|
||||
|
||||
// Tool-call spans with empty model-related attributes should remain as span-create
|
||||
expect(observationEvent?.type).toBe("span-create");
|
||||
});
|
||||
|
||||
it("should use logfire.msg as span name", async () => {
|
||||
const resourceSpan = {
|
||||
scopeSpans: [
|
||||
@@ -1475,6 +1525,18 @@ describe("OTel Resource Span Mapping", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"should cast input_tokens from string to number",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "langfuse.observation.completion_start_time",
|
||||
otelAttributeValue: {
|
||||
stringValue: "2025-09-17T22:16:28.152000+02:00",
|
||||
},
|
||||
entityAttributeKey: "completionStartTime",
|
||||
entityAttributeValue: "2025-09-17T22:16:28.152000+02:00",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should cast input_tokens from string to number",
|
||||
{
|
||||
@@ -2019,6 +2081,30 @@ describe("OTel Resource Span Mapping", () => {
|
||||
entityAttributeValue: '{"foo": "bar"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gcp.vertex.agent.tool_call_args to input",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gcp.vertex.agent.tool_call_args",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"foo": "bar"}',
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: '{"foo": "bar"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map gcp.vertex.agent.tool_response to output",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "gcp.vertex.agent.tool_response",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"foo": "bar"}',
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: '{"foo": "bar"}',
|
||||
},
|
||||
],
|
||||
])(
|
||||
"Attributes: %s",
|
||||
async (
|
||||
@@ -3955,5 +4041,127 @@ describe("OTel Resource Span Mapping", () => {
|
||||
const traceEvents = events.filter((e) => e.type === "trace-create");
|
||||
expect(traceEvents.length).toBe(1);
|
||||
});
|
||||
|
||||
it.skip("should not overwrite existing trace metadata when child span has trace updates", async () => {
|
||||
// skipped because it's not really getting the trace and it's metadata to check
|
||||
const traceId = "95f3b926c7d009925bcb5dbc27311120";
|
||||
|
||||
// 1. Root span creates trace with original metadata
|
||||
const rootSpan = {
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "openinference.instrumentation.google_adk",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
data: [
|
||||
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
|
||||
49, 17, 32,
|
||||
],
|
||||
},
|
||||
spanId: { data: [1, 2, 3, 4, 5, 6, 7, 8] },
|
||||
name: "root-span",
|
||||
startTimeUnixNano: { low: 1, high: 1 },
|
||||
endTimeUnixNano: { low: 2, high: 1 },
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.trace.name",
|
||||
value: { stringValue: "Original Name" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.session.id",
|
||||
value: { stringValue: "original-session" },
|
||||
},
|
||||
{
|
||||
key: "original_span_attribute",
|
||||
value: { stringValue: "should_be_preserved" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 2. Child span with trace updates but different span attributes
|
||||
const childSpan = {
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "openinference.instrumentation.google_adk",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
data: [
|
||||
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
|
||||
49, 17, 32,
|
||||
],
|
||||
},
|
||||
spanId: { data: [2, 2, 3, 4, 5, 6, 7, 8] },
|
||||
parentSpanId: { data: [1, 2, 3, 4, 5, 6, 7, 8] },
|
||||
name: "child-span",
|
||||
startTimeUnixNano: { low: 1, high: 1 },
|
||||
endTimeUnixNano: { low: 2, high: 1 },
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.trace.name",
|
||||
value: { stringValue: "Updated Name" },
|
||||
}, // to trigger hasTraceUpdates
|
||||
{
|
||||
key: "langfuse.session.id",
|
||||
value: { stringValue: "new-session" },
|
||||
}, // also triggers hasTraceUpdates
|
||||
{
|
||||
key: "new_span_attribute",
|
||||
value: { stringValue: "new_value" },
|
||||
},
|
||||
// Note: missing original_span_attribute
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Validate that root span creates trace with span attributes in metadata.attributes
|
||||
const rootEvents = await convertOtelSpanToIngestionEvent(
|
||||
rootSpan,
|
||||
new Set(),
|
||||
publicKey,
|
||||
);
|
||||
const rootTrace = rootEvents.find((e) => e.type === "trace-create");
|
||||
expect(rootTrace?.body.sessionId).toBe("original-session");
|
||||
expect(
|
||||
rootTrace?.body.metadata?.attributes?.original_span_attribute,
|
||||
).toBe("should_be_preserved");
|
||||
|
||||
// Process child span with trace updates
|
||||
const childSpanEvents = await convertOtelSpanToIngestionEvent(
|
||||
childSpan,
|
||||
new Set([traceId]),
|
||||
publicKey,
|
||||
);
|
||||
const updatedTraceEvent = childSpanEvents.find(
|
||||
(e) => e.type === "trace-create",
|
||||
);
|
||||
|
||||
expect(updatedTraceEvent).toBeDefined();
|
||||
|
||||
// original_span_attribute should still exist
|
||||
expect(
|
||||
updatedTraceEvent.body.metadata?.attributes?.original_span_attribute,
|
||||
).toBe("should_be_preserved");
|
||||
|
||||
// new_span_attribute should now exist
|
||||
expect(
|
||||
updatedTraceEvent.body.metadata?.attributes?.new_span_attribute,
|
||||
).toBe("new_value");
|
||||
|
||||
// The sessionId should be updated
|
||||
expect(updatedTraceEvent.body.sessionId).toBe("new-session");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/** @jest-environment node */
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import {
|
||||
BaseError,
|
||||
LangfuseNotFoundError,
|
||||
UnauthorizedError,
|
||||
} from "@langfuse/shared";
|
||||
import {
|
||||
ClickHouseResourceError,
|
||||
logger,
|
||||
traceException,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { z } from "zod/v4";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
// Mock the logger and traceException
|
||||
jest.mock("@langfuse/shared/src/server", () => ({
|
||||
...jest.requireActual("@langfuse/shared/src/server"),
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
traceException: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("withMiddlewares error handling", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("BaseError handling", () => {
|
||||
it("should handle BaseError with 4xx status code", async () => {
|
||||
const error = new BaseError("BadRequest", 400, "Bad Request", false);
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Bad Request",
|
||||
error: "BadRequest",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle BaseError with 5xx status code and trace exception", async () => {
|
||||
const error = new BaseError(
|
||||
"ServiceUnavailable",
|
||||
503,
|
||||
"Internal Error",
|
||||
true,
|
||||
);
|
||||
|
||||
const handler = withMiddlewares({
|
||||
GET: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(503);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Internal Error",
|
||||
error: "ServiceUnavailable",
|
||||
});
|
||||
// Should trace 5xx errors
|
||||
expect(traceException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LangfuseNotFoundError handling", () => {
|
||||
it("should handle LangfuseNotFoundError and log as info", async () => {
|
||||
const error = new LangfuseNotFoundError("Resource not found");
|
||||
|
||||
const handler = withMiddlewares({
|
||||
GET: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(404);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Resource not found",
|
||||
error: "LangfuseNotFoundError",
|
||||
});
|
||||
// Should log as info, not error
|
||||
expect(logger.info).toHaveBeenCalledWith(error);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError handling", () => {
|
||||
it("should handle UnauthorizedError and log as info", async () => {
|
||||
const error = new UnauthorizedError("Invalid credentials");
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(401);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Invalid credentials",
|
||||
error: "UnauthorizedError",
|
||||
});
|
||||
// Should log as info, not error
|
||||
expect(logger.info).toHaveBeenCalledWith(error);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MethodNotAllowedError handling", () => {
|
||||
it("should throw MethodNotAllowedError for unsupported methods", async () => {
|
||||
const handler = withMiddlewares({
|
||||
GET: async () => {},
|
||||
// POST is not defined
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(405);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Method not allowed",
|
||||
error: "MethodNotAllowedError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClickHouseResourceError handling", () => {
|
||||
it("should handle ClickHouseResourceError with 400 status", async () => {
|
||||
const originalError = new Error("Memory limit exceeded: maximum: 10GB");
|
||||
const resourceError = new ClickHouseResourceError(
|
||||
"MEMORY_LIMIT",
|
||||
originalError,
|
||||
);
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
throw resourceError;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(524);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData["message"]).toBeDefined();
|
||||
expect(jsonData["message"]).toContain(
|
||||
ClickHouseResourceError.ERROR_ADVICE_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prisma exception handling", () => {
|
||||
it("should handle Prisma exceptions with generic 500 error", async () => {
|
||||
// Create a real Prisma error
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{ code: "P2002", clientVersion: "5.0.0" },
|
||||
);
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
throw prismaError;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(500);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Internal Server Error",
|
||||
error: "An unknown error occurred",
|
||||
});
|
||||
|
||||
expect(traceException).toHaveBeenCalledWith(prismaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod validation error handling", () => {
|
||||
it("should handle Zod validation errors with 400 status", async () => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const handler = withMiddlewares({
|
||||
POST: async () => {
|
||||
// This will throw a ZodError
|
||||
schema.parse({ name: "John", age: "not a number" });
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Invalid request data",
|
||||
error: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "invalid_type",
|
||||
path: ["age"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Generic error handling", () => {
|
||||
it("should handle generic Error instances with 500 status", async () => {
|
||||
const error = new Error("Something went wrong");
|
||||
|
||||
const handler = withMiddlewares({
|
||||
DELETE: async () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(500);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Internal Server Error",
|
||||
error: "Something went wrong",
|
||||
});
|
||||
|
||||
expect(traceException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("should handle non-Error thrown values with 500 status", async () => {
|
||||
const handler = withMiddlewares({
|
||||
PATCH: async () => {
|
||||
throw "string error"; // eslint-disable-line no-throw-literal
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(500);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Internal Server Error",
|
||||
error: "An unknown error occurred",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle null/undefined errors with 500 status", async () => {
|
||||
const handler = withMiddlewares({
|
||||
PUT: async () => {
|
||||
throw null; // eslint-disable-line no-throw-literal
|
||||
},
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<NextApiRequest, NextApiResponse>({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"x-langfuse-public-key": "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(500);
|
||||
const jsonData = JSON.parse(res._getData());
|
||||
expect(jsonData).toMatchObject({
|
||||
message: "Internal Server Error",
|
||||
error: "An unknown error occurred",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
|
||||
import { stripeWebhookHandler } from "@/src/ee/features/billing/server/stripeWebhookHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = stripeWebhookApiHandler;
|
||||
export const POST = stripeWebhookHandler;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
@@ -6,25 +6,26 @@ import { useState } from "react";
|
||||
export const EnvLabel = ({ className }: { className?: string }) => {
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const session = useSession();
|
||||
if (!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) return null;
|
||||
const { isLangfuseCloud, region } = useLangfuseCloudRegion();
|
||||
if (!isLangfuseCloud) return null;
|
||||
if (!session.data?.user?.email?.endsWith("@langfuse.com")) return null;
|
||||
if (isHidden) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 self-stretch whitespace-nowrap rounded-md px-1 py-0.5 text-xs",
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
region === "STAGING"
|
||||
? "bg-light-blue text-dark-blue"
|
||||
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
|
||||
: region === "DEV"
|
||||
? "bg-light-green text-dark-green"
|
||||
: "bg-light-red text-dark-red",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setIsHidden(true)}
|
||||
>
|
||||
{["EU", "US", "HIPAA"].includes(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
|
||||
? `PROD-${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}`
|
||||
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
|
||||
{region && ["EU", "US", "HIPAA"].includes(region)
|
||||
? `PROD-${region}`
|
||||
: region}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,37 @@
|
||||
type Accuracy = "minute" | "second" | "millisecond";
|
||||
type Accuracy = "day" | "hour" | "minute" | "second" | "millisecond";
|
||||
|
||||
export const formatLocalIsoDate = (
|
||||
date: Date,
|
||||
useUTC = false,
|
||||
pAccuracy: Accuracy,
|
||||
) => {
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
|
||||
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
|
||||
const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
||||
const day = useUTC ? date.getUTCDate() : date.getDate();
|
||||
const hours = useUTC ? date.getUTCHours() : date.getHours();
|
||||
const minutes = useUTC ? date.getUTCMinutes() : date.getMinutes();
|
||||
const seconds = useUTC ? date.getUTCSeconds() : date.getSeconds();
|
||||
const ms = useUTC ? date.getUTCMilliseconds() : date.getMilliseconds();
|
||||
|
||||
let formatted = `${year}-${pad(month)}-${pad(day)}`;
|
||||
|
||||
if (["hour", "minute", "second", "millisecond"].includes(pAccuracy)) {
|
||||
formatted += ` ${pad(hours)}`;
|
||||
}
|
||||
if (["minute", "second", "millisecond"].includes(pAccuracy)) {
|
||||
formatted += `:${pad(minutes)}`;
|
||||
}
|
||||
if (["second", "millisecond"].includes(pAccuracy)) {
|
||||
formatted += `:${pad(seconds)}`;
|
||||
}
|
||||
if (pAccuracy === "millisecond") {
|
||||
formatted += `.${String(ms).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
export const LocalIsoDate = ({
|
||||
date,
|
||||
@@ -13,31 +46,8 @@ export const LocalIsoDate = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date, useUTC = false, pAccuracy: Accuracy) => {
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
|
||||
const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
|
||||
const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
||||
const day = useUTC ? date.getUTCDate() : date.getDate();
|
||||
const hours = useUTC ? date.getUTCHours() : date.getHours();
|
||||
const minutes = useUTC ? date.getUTCMinutes() : date.getMinutes();
|
||||
const seconds = useUTC ? date.getUTCSeconds() : date.getSeconds();
|
||||
const ms = useUTC ? date.getUTCMilliseconds() : date.getMilliseconds();
|
||||
|
||||
let formatted = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`;
|
||||
|
||||
if (["second", "millisecond"].includes(pAccuracy)) {
|
||||
formatted += `:${pad(seconds)}`;
|
||||
}
|
||||
if (pAccuracy === "millisecond") {
|
||||
formatted += `.${String(ms).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const localDateString = formatDate(date, false, accuracy);
|
||||
const utcDateString = formatDate(date, true, "millisecond");
|
||||
const localDateString = formatLocalIsoDate(date, false, accuracy);
|
||||
const utcDateString = formatLocalIsoDate(date, true, "millisecond");
|
||||
|
||||
return (
|
||||
<span title={`UTC: ${utcDateString}`} className={className}>
|
||||
|
||||
@@ -20,20 +20,22 @@ import {
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { usePlan } from "@/src/features/entitlements/hooks";
|
||||
import { isSelfHostedPlan, planLabels } from "@langfuse/shared";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const isLangfuseCloud = useLangfuseCloudRegion();
|
||||
|
||||
const backgroundMigrationStatus = api.backgroundMigrations.status.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
|
||||
enabled: !isLangfuseCloud, // do not check for updates on Langfuse Cloud
|
||||
throwOnError: false, // do not render default error message
|
||||
},
|
||||
);
|
||||
@@ -42,12 +44,11 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION, // do not check for updates on Langfuse Cloud
|
||||
enabled: !isLangfuseCloud, // do not check for updates on Langfuse Cloud
|
||||
throwOnError: false, // do not render default error message
|
||||
});
|
||||
|
||||
const plan = usePlan();
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const selfHostedPlanLabel = !isLangfuseCloud
|
||||
? plan && isSelfHostedPlan(plan)
|
||||
@@ -107,7 +108,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : !env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION ? (
|
||||
) : !isLangfuseCloud ? (
|
||||
<>
|
||||
<DropdownMenuLabel>This is the latest release</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Calendar as CalendarIcon, X } from "lucide-react";
|
||||
import { Calendar as CalendarIcon, X, ChevronDown } from "lucide-react";
|
||||
import { addMinutes } from "date-fns";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Calendar } from "@/src/components/ui/calendar";
|
||||
import {
|
||||
@@ -10,10 +11,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type DateRange } from "react-day-picker";
|
||||
import { type DateRange as RDPDateRange } from "react-day-picker";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
|
||||
import { TimePicker } from "@/src/components/ui/time-picker";
|
||||
import { DashboardDateRangeDropdown } from "@/src/components/date-range-dropdowns";
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type DashboardDateRange,
|
||||
TIME_RANGES,
|
||||
formatDateRange,
|
||||
type TimeRange,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { combineDateAndTime } from "@/src/components/ui/time-picker-utils";
|
||||
|
||||
@@ -65,7 +68,7 @@ export function DatePicker({
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d) => onChange(d)}
|
||||
initialFocus
|
||||
autoFocus
|
||||
/>
|
||||
{includeTimePicker && (
|
||||
<TimePicker date={date} setDate={(d) => onChange(d)} />
|
||||
@@ -105,7 +108,7 @@ export function DatePickerWithRange({
|
||||
disabled,
|
||||
}: DatePickerWithRangeProps) {
|
||||
const [internalDateRange, setInternalDateRange] = useState<
|
||||
DateRange | undefined
|
||||
RDPDateRange | undefined
|
||||
>(dateRange);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -113,10 +116,10 @@ export function DatePickerWithRange({
|
||||
}, [dateRange]);
|
||||
|
||||
const setNewDateRange = (
|
||||
internalDateRange: DateRange | undefined,
|
||||
internalDateRange: RDPDateRange | undefined,
|
||||
newFromDate: Date | undefined,
|
||||
newToDate: Date | undefined,
|
||||
): DateRange | undefined => {
|
||||
): RDPDateRange | undefined => {
|
||||
return internalDateRange
|
||||
? {
|
||||
from: newFromDate ?? internalDateRange.from,
|
||||
@@ -126,7 +129,7 @@ export function DatePickerWithRange({
|
||||
};
|
||||
|
||||
const updateDashboardDateRange = (
|
||||
newRange: DateRange | undefined,
|
||||
newRange: RDPDateRange | undefined,
|
||||
setDateRangeAndOption: (
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
@@ -144,7 +147,7 @@ export function DatePickerWithRange({
|
||||
}
|
||||
};
|
||||
|
||||
const onCalendarSelection = (range?: DateRange) => {
|
||||
const onCalendarSelection = (range?: RDPDateRange) => {
|
||||
const newRange = range
|
||||
? {
|
||||
from: range.from ? setBeginningOfDay(range.from) : undefined,
|
||||
@@ -178,8 +181,6 @@ export function DatePickerWithRange({
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const isSmallScreen = useMediaQuery({ query: "(max-width: 640px)" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("my-3 flex flex-col-reverse gap-2 md:flex-row", className)}
|
||||
@@ -216,49 +217,32 @@ export function DatePickerWithRange({
|
||||
defaultMonth={internalDateRange?.from}
|
||||
selected={internalDateRange}
|
||||
onSelect={onCalendarSelection}
|
||||
numberOfMonths={isSmallScreen ? 1 : 2}
|
||||
numberOfMonths={2}
|
||||
className="[&>div:first-child]:block [&>div]:hidden [&>div]:sm:block"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!isSmallScreen && (
|
||||
<div className="flex flex-row border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t-2 py-1.5 sm:flex-row sm:gap-0">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">
|
||||
Start<span className="hidden sm:inline"> time</span>
|
||||
</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<div className="flex flex-col gap-2 border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">
|
||||
End<span className="hidden sm:inline"> time</span>
|
||||
</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DashboardDateRangeDropdown
|
||||
@@ -268,3 +252,270 @@ export function DatePickerWithRange({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TimeRangePickerProps = {
|
||||
timeRange?: TimeRange;
|
||||
onTimeRangeChange: (timeRange: TimeRange) => void;
|
||||
timeRangePresets: readonly string[];
|
||||
className?: string;
|
||||
disabled?: boolean | { before?: Date; after?: Date } | Date | Date[];
|
||||
};
|
||||
|
||||
export function TimeRangePicker({
|
||||
className,
|
||||
timeRange,
|
||||
timeRangePresets,
|
||||
onTimeRangeChange,
|
||||
disabled,
|
||||
}: TimeRangePickerProps) {
|
||||
// Determine the range type
|
||||
const rangeType: "named" | "custom" | null = timeRange
|
||||
? "from" in timeRange
|
||||
? "custom"
|
||||
: "named"
|
||||
: null;
|
||||
|
||||
// Disable future dates by default, plus any additional disabled prop
|
||||
const calendarDisabled = React.useMemo(() => {
|
||||
const futureDisabled = { after: new Date() };
|
||||
|
||||
if (!disabled) return futureDisabled;
|
||||
if (typeof disabled === "boolean") return disabled;
|
||||
|
||||
// Always return an array when combining with additional restrictions
|
||||
const disabledArray = Array.isArray(disabled) ? disabled : [disabled];
|
||||
return [...disabledArray, futureDisabled] as React.ComponentProps<
|
||||
typeof Calendar
|
||||
>["disabled"];
|
||||
}, [disabled]);
|
||||
const namedRangeValue =
|
||||
rangeType === "named" && timeRange && "range" in timeRange
|
||||
? timeRange.range
|
||||
: null;
|
||||
|
||||
// Convert TimeRange to DateRange for internal use
|
||||
const dateRange = timeRange && "from" in timeRange ? timeRange : undefined;
|
||||
|
||||
const [internalDateRange, setInternalDateRange] = useState<
|
||||
RDPDateRange | undefined
|
||||
>(dateRange);
|
||||
|
||||
// Update internal date range when timeRange changes
|
||||
useEffect(() => {
|
||||
if (rangeType === "custom") {
|
||||
// Custom range - use as is
|
||||
setInternalDateRange(dateRange);
|
||||
} else if (rangeType === "named" && timeRange && "range" in timeRange) {
|
||||
// Preset range - look up in generic time ranges
|
||||
const setting = TIME_RANGES[timeRange.range as keyof typeof TIME_RANGES];
|
||||
if (setting && setting.minutes) {
|
||||
const now = new Date();
|
||||
setInternalDateRange({
|
||||
from: addMinutes(now, -setting.minutes),
|
||||
to: now,
|
||||
});
|
||||
} else {
|
||||
setInternalDateRange(undefined);
|
||||
}
|
||||
} else {
|
||||
setInternalDateRange(undefined);
|
||||
}
|
||||
}, [timeRange, dateRange, rangeType]);
|
||||
|
||||
const setNewDateRange = (
|
||||
internalDateRange: RDPDateRange | undefined,
|
||||
newFromDate: Date | undefined,
|
||||
newToDate: Date | undefined,
|
||||
): RDPDateRange | undefined => {
|
||||
return internalDateRange
|
||||
? {
|
||||
from: newFromDate ?? internalDateRange.from,
|
||||
to: newToDate ?? internalDateRange.to,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const updateDateRange = (newRange: RDPDateRange | undefined) => {
|
||||
if (newRange && newRange.from && newRange.to) {
|
||||
onTimeRangeChange({
|
||||
from: newRange.from,
|
||||
to: newRange.to,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onCalendarSelection = (range?: RDPDateRange) => {
|
||||
const newRange = range
|
||||
? {
|
||||
from: range.from
|
||||
? setBeginningOfDay(new Date(range.from))
|
||||
: undefined,
|
||||
to: range.to ? setEndOfDay(new Date(range.to)) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
setInternalDateRange(newRange);
|
||||
updateDateRange(newRange);
|
||||
};
|
||||
|
||||
const onStartTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.from, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
newDateTime,
|
||||
internalDateRange?.to,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDateRange(newRange);
|
||||
};
|
||||
|
||||
const onEndTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.to, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
internalDateRange?.from,
|
||||
newDateTime,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDateRange(newRange);
|
||||
};
|
||||
|
||||
const onPresetSelection = (value: string) => {
|
||||
if (timeRangePresets.includes(value as keyof typeof TIME_RANGES)) {
|
||||
onTimeRangeChange({ range: value });
|
||||
}
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"presets" | "calendar">("presets");
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
setTab("presets");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getDisplayContent = () => {
|
||||
if (rangeType === "custom") {
|
||||
// Custom range - show calendar icon and date range
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{dateRange
|
||||
? formatDateRange(dateRange.from, dateRange.to)
|
||||
: "Select from calendar"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (rangeType === "named") {
|
||||
// Preset range - show badge with abbreviation and label
|
||||
const setting = TIME_RANGES[namedRangeValue as keyof typeof TIME_RANGES];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-5 w-10 rounded bg-muted px-1.5 text-center text-xs leading-5">
|
||||
{setting?.abbreviation || namedRangeValue}
|
||||
</span>
|
||||
<span>{setting?.label || namedRangeValue}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// No time range selected
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
<span>Select time range</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("my-3", className)}>
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-fit justify-start text-left font-normal hover:bg-accent hover:text-accent-foreground",
|
||||
!timeRange && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getDisplayContent()}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
{tab === "calendar" ? (
|
||||
/* Show calendar picker when in calendar mode */
|
||||
<>
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={internalDateRange?.from || new Date()}
|
||||
selected={internalDateRange}
|
||||
onSelect={onCalendarSelection}
|
||||
numberOfMonths={1}
|
||||
disabled={calendarDisabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="px-1 text-sm font-medium">Start time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 py-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="px-1 text-sm font-medium">End time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 py-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Always show preset options dropdown */
|
||||
<div className="p-1">
|
||||
{timeRangePresets.map((presetKey) => {
|
||||
const setting =
|
||||
TIME_RANGES[presetKey as keyof typeof TIME_RANGES];
|
||||
return (
|
||||
<div
|
||||
key={presetKey}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onPresetSelection(presetKey);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="h-5 w-10 rounded bg-muted px-1.5 text-center text-xs leading-5">
|
||||
{setting.abbreviation}
|
||||
</span>
|
||||
<span>{setting.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setTab("calendar");
|
||||
}}
|
||||
>
|
||||
<span className="leading flex h-5 w-10 items-center justify-center rounded bg-muted px-1.5 text-center text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span>Select from calendar</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
getDateFromOption,
|
||||
isTableDataRangeOptionAvailable,
|
||||
isDashboardDateRangeOptionAvailable,
|
||||
getAbbreviatedTimeRange,
|
||||
getTimeRangeLabel,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
|
||||
import { useMemo } from "react";
|
||||
@@ -45,24 +47,40 @@ const BaseDateRangeDropdown = <T extends string>({
|
||||
return (
|
||||
<Select value={selectedOption} onValueChange={onSelectionChange}>
|
||||
<SelectTrigger className="w-fit font-medium hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
{selectedOption !== "All time" && <span>Past</span>}
|
||||
<SelectValue placeholder="Select" />
|
||||
<SelectValue placeholder="Select">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 rounded bg-muted px-1.5 py-0.5 text-center text-xs">
|
||||
{getAbbreviatedTimeRange(selectedOption)}
|
||||
</span>
|
||||
<span>{getTimeRangeLabel(selectedOption)}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
defaultValue={60}
|
||||
className="[&_[data-checkmark]]:hidden"
|
||||
>
|
||||
{options.map((item) => {
|
||||
const itemObj = (
|
||||
<SelectItem
|
||||
key={item}
|
||||
value={item}
|
||||
disabled={limitedOptions?.includes(item)}
|
||||
className="pl-2"
|
||||
>
|
||||
{item}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 rounded bg-muted px-1.5 py-0.5 text-center text-xs">
|
||||
{getAbbreviatedTimeRange(item)}
|
||||
</span>
|
||||
<span>{getTimeRangeLabel(item)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
const isLimited = limitedOptions?.includes(item);
|
||||
|
||||
return isLimited ? (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCard openDelay={200} key={item}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span>{itemObj}</span>
|
||||
</HoverCardTrigger>
|
||||
@@ -113,7 +131,7 @@ export const DashboardDateRangeDropdown: React.FC<
|
||||
value as keyof typeof dashboardDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting.minutes),
|
||||
from: addMinutes(new Date(), -setting!.minutes!),
|
||||
to: new Date(),
|
||||
});
|
||||
};
|
||||
@@ -153,6 +171,12 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
}, [lookbackLimit]);
|
||||
|
||||
const onDropDownSelection = (value: TableDateRangeOptions) => {
|
||||
// Handle "custom" placeholder case
|
||||
if (value === "custom") {
|
||||
setDateRangeAndOption(value, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFromOption = getDateFromOption({
|
||||
filterSource: "TABLE",
|
||||
option: value,
|
||||
|
||||
@@ -70,7 +70,7 @@ const ScoreGroupBadge = <T extends APIScoreV2 | LastUserScore>({
|
||||
<HoverCardTrigger className="inline-block">
|
||||
<MessageCircleMoreIcon className="mb-[0.0625rem] !size-3" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal">
|
||||
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal text-xs">
|
||||
<p className="whitespace-pre-wrap">{s.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
@@ -80,7 +80,7 @@ const ScoreGroupBadge = <T extends APIScoreV2 | LastUserScore>({
|
||||
<HoverCardTrigger className="inline-block">
|
||||
<BracesIcon className="mb-[0.0625rem] !size-3" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal rounded-md border-none p-0">
|
||||
<HoverCardContent className="max-h-[50dvh] overflow-y-auto whitespace-normal break-normal rounded-md border-none p-0 text-xs">
|
||||
<JSONView codeClassName="!rounded-md" json={s.metadata} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
processNavigation,
|
||||
type NavigationItem,
|
||||
} from "@/src/components/layouts/utilities/routes";
|
||||
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
const signOutUser = async () => {
|
||||
sessionStorage.clear();
|
||||
@@ -119,6 +120,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
| string
|
||||
| undefined;
|
||||
const session = useSessionWithRetryOnUnauthenticated();
|
||||
const { isLangfuseCloud, region } = useLangfuseCloudRegion();
|
||||
|
||||
const enableExperimentalFeatures =
|
||||
session.data?.environment.enableExperimentalFeatures ?? false;
|
||||
@@ -127,9 +129,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const cloudAdmin =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
|
||||
session.data?.user?.admin === true;
|
||||
const cloudAdmin = isLangfuseCloud && session.data?.user?.admin === true;
|
||||
|
||||
// project info based on projectId in the URL
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
@@ -319,13 +319,13 @@ export default function Layout(props: PropsWithChildren) {
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${region === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${region === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
</Head>
|
||||
<div>
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
import { LangfuseLogo } from "@/src/components/LangfuseLogo";
|
||||
import { SidebarNotifications } from "@/src/components/nav/sidebar-notifications";
|
||||
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
|
||||
import { type RouteGroup } from "@/src/components/layouts/routes";
|
||||
import { ExternalLink, Grid2X2 } from "lucide-react";
|
||||
import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks";
|
||||
|
||||
type AppSidebarProps = {
|
||||
navItems: {
|
||||
@@ -75,6 +75,7 @@ export function AppSidebar({
|
||||
|
||||
const DemoBadge = () => {
|
||||
const router = useRouter();
|
||||
const { isLangfuseCloud } = useLangfuseCloudRegion();
|
||||
const routerProjectId = router.query.projectId as string | undefined;
|
||||
|
||||
if (
|
||||
@@ -82,7 +83,7 @@ const DemoBadge = () => {
|
||||
env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
|
||||
isLangfuseCloud
|
||||
)
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -29,6 +29,15 @@ type SidebarNotification = {
|
||||
};
|
||||
|
||||
const notifications: SidebarNotification[] = [
|
||||
{
|
||||
id: "js-sdk-v4",
|
||||
title: "New JS/TS SDK v4",
|
||||
description:
|
||||
"With v4, the TypeScript SDK significantly improves DX, speed, and ecosystem integrations.",
|
||||
link: "https://langfuse.com/docs/observability/sdk/typescript/overview",
|
||||
linkTitle: "Learn more",
|
||||
createdAt: "2025-09-09",
|
||||
},
|
||||
{
|
||||
id: "python-sdk-v3",
|
||||
title: "New Python SDK v3",
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ScoresTableCell = ({
|
||||
<HoverCardTrigger className="inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal text-xs">
|
||||
<p className="whitespace-pre-wrap">{aggregate.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
@@ -111,7 +111,7 @@ export const ScoresTableCell = ({
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal">
|
||||
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal text-xs">
|
||||
<ScoreValueCounts valueCounts={aggregate.valueCounts} wrap={wrap} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
@@ -159,7 +159,7 @@ function AggregateScoreMetadataPeek({
|
||||
<HoverCardTrigger className="inline-block cursor-pointer">
|
||||
<BracesIcon size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal rounded-md border-none p-0">
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal rounded-md border-none p-0 text-xs">
|
||||
{metadataLoaded ? (
|
||||
<JSONView codeClassName="!rounded-md" json={metadata} />
|
||||
) : (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user