Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b662c95e5 | ||
|
|
87e94289ff | ||
|
|
ed08331927 | ||
|
|
02e5c0119a | ||
|
|
adc39d58e4 | ||
|
|
19987e9816 | ||
|
|
746299431f | ||
|
|
74224a04cd | ||
|
|
874d574a9e | ||
|
|
13655515a2 | ||
|
|
204ce02bd1 | ||
|
|
b87f84bf67 | ||
|
|
e139b40167 | ||
|
|
f3596fac10 | ||
|
|
f1d15ea268 | ||
|
|
5183cf4859 | ||
|
|
604ff3203e | ||
|
|
ca8ba2f233 | ||
|
|
f9c5534a20 | ||
|
|
f652c75304 | ||
|
|
879a72b821 | ||
|
|
d3cb8469c0 | ||
|
|
b5b93cf710 | ||
|
|
27caeb1536 | ||
|
|
3b40c67aae | ||
|
|
79a6bc115f | ||
|
|
8aa1e0e1b8 | ||
|
|
217926b049 | ||
|
|
8ed92de662 | ||
|
|
2a9421674b | ||
|
|
0b2bf8535d | ||
|
|
b89e2423c3 | ||
|
|
43340dd6ff | ||
|
|
e529d887ba | ||
|
|
d7d79df5cf | ||
|
|
59de947865 | ||
|
|
c5ab61b356 | ||
|
|
13db6781e2 | ||
|
|
5442429f47 | ||
|
|
c01600b2b8 | ||
|
|
19101723b3 | ||
|
|
989522b22a | ||
|
|
8b4a2893d5 | ||
|
|
6719dd4158 | ||
|
|
94d87f2a42 | ||
|
|
e06272c4df | ||
|
|
f6512d7a8e | ||
|
|
5e67d897b7 | ||
|
|
9adcfde4cc | ||
|
|
bc8f8656a1 | ||
|
|
85a327ef26 | ||
|
|
46b20f4107 | ||
|
|
fb8f26a00e | ||
|
|
3d7ba475e2 | ||
|
|
03028a92b4 | ||
|
|
8a96290f03 | ||
|
|
bca09bdefe | ||
|
|
2d314250e9 | ||
|
|
4a87f402ec | ||
|
|
800b2136b9 | ||
|
|
278df5ab3c | ||
|
|
c7f9e18768 | ||
|
|
2121b40cca | ||
|
|
5ba440f2de | ||
|
|
657a3275de | ||
|
|
56bb9c8dcf | ||
|
|
66e15bcf0d | ||
|
|
5f94ca8e82 | ||
|
|
e8561fb105 | ||
|
|
18e84c6498 | ||
|
|
05152ac8d6 | ||
|
|
6cb9e24ba2 | ||
|
|
484fa424fb | ||
|
|
555aebe8c7 | ||
|
|
af29424b48 | ||
|
|
42f1c63bbf | ||
|
|
663ded0743 | ||
|
|
d7ced99d16 | ||
|
|
c979cae620 | ||
|
|
c483293b10 | ||
|
|
932913cd76 | ||
|
|
5875537132 | ||
|
|
1a53981a7a | ||
|
|
0e6b676b19 | ||
|
|
955e25555f | ||
|
|
2a11f1e8fb | ||
|
|
143a461f0c | ||
|
|
c8544690f7 | ||
|
|
05a533dd7e | ||
|
|
f3d15bb36e | ||
|
|
24c321f632 | ||
|
|
0e9cf548b2 | ||
|
|
7158a41fe9 | ||
|
|
bc384a525a | ||
|
|
3f70828df6 | ||
|
|
0988635427 | ||
|
|
41ed6fed5e | ||
|
|
eb40659e44 | ||
|
|
a22bcb1188 | ||
|
|
392fbe6c8c | ||
|
|
48d5a8f5a1 | ||
|
|
44428870f7 | ||
|
|
724a4f5d9a | ||
|
|
2358264b66 | ||
|
|
65e3b4c5ca | ||
|
|
62c413faf5 | ||
|
|
8ea14ac537 | ||
|
|
c32b84663c | ||
|
|
23ab79ba42 | ||
|
|
6ffec3337f | ||
|
|
34a2a9cddf | ||
|
|
0854429850 | ||
|
|
05fd690486 | ||
|
|
bc3f39272b | ||
|
|
96ace71f9a | ||
|
|
70415dcab5 | ||
|
|
6a18f249aa | ||
|
|
cc60d506a7 | ||
|
|
a31a7f238e | ||
|
|
41ebc77f86 | ||
|
|
cdc41cf573 | ||
|
|
c86e32f756 | ||
|
|
85576c162a | ||
|
|
93ad1e6880 | ||
|
|
97b17fc8bb | ||
|
|
04e2d9249f | ||
|
|
cb94a54f0c | ||
|
|
7c90bbed0c | ||
|
|
f133526ba6 | ||
|
|
dc014de54c | ||
|
|
c6beec1ad5 | ||
|
|
a810f7750f | ||
|
|
2b34c82e98 | ||
|
|
fe283284ef | ||
|
|
307f3d18ce | ||
|
|
46fff7148e | ||
|
|
9cce111be6 | ||
|
|
dbd7f810c1 | ||
|
|
abc3ea2b5a | ||
|
|
3d8a4f9d75 | ||
|
|
7b4bef7c2e | ||
|
|
b10353115b | ||
|
|
f3235ceccb | ||
|
|
92455b4e5a | ||
|
|
217b92d548 | ||
|
|
c6ddd3822f | ||
|
|
67d6c32ec5 | ||
|
|
d89379e693 | ||
|
|
4b8d3a764e | ||
|
|
5757fa6e38 | ||
|
|
34c3e5af08 | ||
|
|
c68ec2fca0 | ||
|
|
03dda153e8 | ||
|
|
63641521d8 | ||
|
|
29d11642c4 | ||
|
|
fb98a404d2 | ||
|
|
032fce56c4 | ||
|
|
518b8df2da | ||
|
|
70761f7ab0 | ||
|
|
a85d4ccf07 | ||
|
|
1b42eae7dd |
@@ -51,3 +51,6 @@ REDIS_AUTH="myredissecret"
|
||||
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
@@ -28,11 +28,18 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
# Configure base path for self-hosting, optional
|
||||
# Note: You need to build the docker image with the base path set and cannot use the pre-built docker image if you set this.
|
||||
# NEXT_PUBLIC_BASE_PATH="/app"
|
||||
|
||||
# Docker only, optional
|
||||
# PORT=3000
|
||||
# HOSTNAME=localhost
|
||||
|
||||
# Opentelemetry, optional
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
|
||||
OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# Default role for users who sign up, optional, can be org or org+project
|
||||
# LANGFUSE_DEFAULT_ORG_ID=
|
||||
# LANGFUSE_DEFAULT_ORG_ROLE=
|
||||
@@ -60,6 +67,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_GITLAB_CLIENT_ID=
|
||||
# AUTH_GITLAB_CLIENT_SECRET=
|
||||
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AZURE_AD_CLIENT_ID=
|
||||
# AUTH_AZURE_AD_CLIENT_SECRET=
|
||||
# AUTH_AZURE_AD_TENANT_ID=
|
||||
@@ -97,6 +107,18 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# S3_REGION=
|
||||
# BATCH_EXPORT_DOWNLOAD_LINK_EXPIRATION_HOURS=
|
||||
|
||||
# S3 storage for events, optional, used to persist all incoming events
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ENABLED="true"
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
|
||||
# Optional prefix to be used within the bucket. Must end with `/` if set
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
# The following four options are optional and fallback to the normal SDK credential provider chain if omitted
|
||||
# See https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_REGION=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
|
||||
|
||||
# Exports are streamed to S3 in pages to avoid memory issues
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
@@ -124,6 +146,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
|
||||
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
|
||||
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
|
||||
# LANGFUSE_UI_LOGO_LIGHT_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-light-mode.png
|
||||
# LANGFUSE_UI_LOGO_DARK_MODE_HREF=https://static.langfuse.com/langfuse-dev/example-logo-dark-mode.png
|
||||
# LANGFUSE_UI_DEFAULT_MODEL_ADAPTER=Anthropic # OpenAI, Anthropic, Azure
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ updates:
|
||||
patterns:
|
||||
- "lodash"
|
||||
- "@types/lodash"
|
||||
express:
|
||||
patterns:
|
||||
- "express"
|
||||
- "@types/express"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: WorkflowCall - Deploy to ECS Service
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
type: string
|
||||
description: Deployment environment
|
||||
required: true
|
||||
service:
|
||||
type: string
|
||||
description: Name of the service to be deployed, e.g. web-ingestion, web, or worker.
|
||||
required: true
|
||||
jobs:
|
||||
ecs-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environment }}
|
||||
steps:
|
||||
- name: Get app name
|
||||
uses: winterjung/split@v2
|
||||
id: split
|
||||
with:
|
||||
msg: ${{ inputs.service }}
|
||||
separator: "-"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Authenticate with AWS
|
||||
# GitHub/AWS recommend to use OIDC here: https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#oidc
|
||||
# Probably more painful to configure, but would remove all long-lived credentials.
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
- name: Build, tag, and push Docker image
|
||||
env:
|
||||
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
REPOSITORY: ${{ steps.split.outputs._0 }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/$REPOSITORY:$IMAGE_TAG \
|
||||
-f ./${{ steps.split.outputs._0 }}/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${{ vars.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION }} \
|
||||
--build-arg NEXT_LANGFUSE_TRACING_SAMPLE_RATE=${{ vars.NEXT_LANGFUSE_TRACING_SAMPLE_RATE }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_ORG_ID=${{ vars.NEXT_PUBLIC_DEMO_ORG_ID }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_PROJECT_ID=${{ vars.NEXT_PUBLIC_DEMO_PROJECT_ID }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
|
||||
--build-arg NEXT_PUBLIC_BUILD_ID=${{ github.sha }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} \
|
||||
--build-arg NEXT_PUBLIC_CRISP_WEBSITE_ID=${{ vars.NEXT_PUBLIC_CRISP_WEBSITE_ID }} \
|
||||
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
|
||||
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
|
||||
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
|
||||
.
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
|
||||
- name: Render AWS ECS Task Definition
|
||||
id: render-task-definition
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
||||
with:
|
||||
container-name: ${{ inputs.service }}
|
||||
image: ${{ steps.login-ecr.outputs.registry }}/${{ steps.split.outputs._0 }}:${{ github.sha }}
|
||||
task-definition-family: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
- name: Update AWS ECS Service
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
|
||||
with:
|
||||
task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
|
||||
service: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
cluster: ${{ inputs.environment }}-cluster
|
||||
wait-for-service-stability: true
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Start containers
|
||||
run: docker compose -f "docker-compose.yml" up -d --build
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
|
||||
- name: Setup Node 18
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "production", "v*"]
|
||||
pull_request:
|
||||
branches: ["main", "production", "v*"]
|
||||
schedule:
|
||||
- cron: "31 13 * * 0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -0,0 +1,105 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- production
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: 'Service to be deployed'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- web
|
||||
- web-ingestion
|
||||
- worker
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- prod-eu-temp
|
||||
- prod-us
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
# Support concurrent `push` and `workflow_dispatch`` actions
|
||||
group: deploy-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Deploy to ECS
|
||||
jobs:
|
||||
affected-services:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
services: ${{ steps.affected-services.outputs.result }}
|
||||
steps:
|
||||
- name: Get affected services
|
||||
id: affected-services
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
if (context.payload.inputs.service === "all") {
|
||||
return `["web", "web-ingestion", "worker"]`
|
||||
}
|
||||
return `["${context.payload.inputs.service}"]`
|
||||
}
|
||||
if (context.eventName === "push") {
|
||||
return `["web", "web-ingestion", "worker"]`
|
||||
}
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print services to build
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
services: ${{ steps.affected-services.outputs.result }}
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
console.log('Services', `${process.env.services}` ?? 'n/a');
|
||||
|
||||
affected-environments:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
environments: ${{ steps.affected-environments.outputs.result }}
|
||||
steps:
|
||||
- name: Get affected environments
|
||||
id: affected-environments
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
return `["${context.payload.inputs.environment}"]`
|
||||
}
|
||||
if (context.eventName === "push") {
|
||||
if (context.ref === "refs/heads/main") {
|
||||
return `["staging"]`
|
||||
}
|
||||
if (context.ref === "refs/heads/production") {
|
||||
return `["prod-eu-temp", "prod-us"]`
|
||||
}
|
||||
}
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print environments to build
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
environments: ${{ steps.affected-environments.outputs.result }}
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
console.log('Environments', `${process.env.environments}` ?? 'n/a');
|
||||
|
||||
ecs-deploy:
|
||||
uses: ./.github/workflows/_deploy_ecs_service.yml
|
||||
needs: [affected-services, affected-environments]
|
||||
secrets: inherit
|
||||
strategy:
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.affected-services.outputs.services) }}
|
||||
environment: ${{ fromJson(needs.affected-environments.outputs.environments) }}
|
||||
with:
|
||||
service: ${{ matrix.service }}
|
||||
environment: ${{ matrix.environment }}
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
@@ -35,10 +35,11 @@ jobs:
|
||||
run: pnpm run lint
|
||||
|
||||
test-docker-build:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
@@ -48,14 +49,6 @@ jobs:
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
|
||||
- name: Check server health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
|
||||
|
||||
- name: Check worker health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
|
||||
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
if docker-compose ps | grep "(unhealthy)"; then
|
||||
@@ -65,6 +58,14 @@ jobs:
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
|
||||
- name: Check worker health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
|
||||
|
||||
- name: Check server health
|
||||
run: |
|
||||
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
|
||||
|
||||
tests-web:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,12 +80,12 @@ jobs:
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -97,7 +98,7 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
grep -v -e '^REDIS_HOST=' .env.dev.example > .env
|
||||
grep -v -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -113,10 +114,6 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
@@ -148,13 +145,13 @@ jobs:
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -205,11 +202,11 @@ jobs:
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
@@ -247,11 +244,11 @@ jobs:
|
||||
e2e-server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
@@ -325,13 +322,13 @@ jobs:
|
||||
with:
|
||||
version: 9.5.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to web
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
@@ -1,30 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
name: Deploy to web (Staging)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4037"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
|
||||
PORTER_HOST: https://dashboard.getporter.dev
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
@@ -1,30 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to web (US temp)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4054"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
@@ -1,29 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to worker (EU temp)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
@@ -1,29 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
name: Deploy to worker (staging)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4037"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: c22d8fa2-f97c-4e1f-9cae-83cc6bfbad35
|
||||
PORTER_HOST: https://dashboard.getporter.dev
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
|
||||
@@ -1,29 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to worker (US temp)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4054"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: cddc0f2a-bb19-401a-9819-29f59f76b101
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
|
||||
Vendored
+2
-2
@@ -2,10 +2,10 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"name": "Web and Worker: server side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
"command": "pnpm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||

|
||||

|
||||
|
||||
<div align="center"><h1> Langfuse: Open Source LLM Observability & Engineering Platform</h4></div>
|
||||
<div align="center"><h2> Debug and improve your LLM app</h4></div>
|
||||
<div align="center"><h3> LLM Observability, Prompt Management, LLM Evaluations, Datasets, LLM Metrics and Prompt Playground</h4></div>
|
||||
<div align="center"><h1>Langfuse: Open Source LLM Engineering Platform</h1></div>
|
||||
<div align="center"><h4>LLM Observability, Prompt Management, LLM Evaluations,<br/>Datasets, LLM Metrics, and Prompt Playground</h4></div>
|
||||
|
||||
<div align="center">
|
||||
<div>
|
||||
@@ -28,15 +27,15 @@
|
||||
</div>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<br/>
|
||||
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in Backend Engineering, Product Engineering, and Developer Relations.</span>
|
||||
<span>We're hiring. <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in Product Engineering and Developer Relations.</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<div>
|
||||
<a href="https://github.com/langfuse/langfuse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License"></a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/v/langfuse?style=flat-square&label=npm+langfuse" alt="langfuse npm package"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/v/langfuse.svg?style=flat-square&label=pypi+langfuse" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/dm/langfuse?style=flat-square&logo=python&logoColor=white&label=pypi%20langfuse&color=blue" alt="langfuse Python package on PyPi"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/dm/langfuse?style=flat-square&logo=npm&logoColor=white&label=npm%20langfuse&color=blue" alt="langfuse npm package"></a>
|
||||
</div>
|
||||
</div>
|
||||
</br>
|
||||
@@ -93,10 +92,10 @@ docker compose up -d
|
||||
|
||||
### Self-host (docker)
|
||||
|
||||
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container.
|
||||
Langfuse is simple to self-host and keep updated. It currently requires only a single docker container and a postgres database.
|
||||
[→ Self Hosting Instructions](https://langfuse.com/docs/deployment/self-host)
|
||||
|
||||
Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
|
||||
Templated deployments: [Railway, GCP, AWS, Azure, Kubernetes and others](https://langfuse.com/docs/deployment/self-host#platform-specific-information)
|
||||
|
||||
## Get Started
|
||||
|
||||
@@ -108,31 +107,31 @@ You need a Langfuse public and secret key to get started. Sign up [here](https:/
|
||||
|
||||
Note: We recommend using our fully async, typed [SDKs](https://langfuse.com/docs/sdk) that allow you to instrument any LLM application with any underlying model. They are available in [Python (Decorators)](https://langfuse.com/docs/sdk/python) & [JS/TS](https://langfuse.com/docs/sdk/typescript). The SDKs will always be the most fully featured and stable way to ingest data into Langfuse.
|
||||
|
||||
You may want to use another integration to get started quickly or implement a use case that we do not yet support. However, we recommend to migrate to the Langfuse SDKs over time to ensure performance and stability.
|
||||
|
||||
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
|
||||
|
||||
### LLM Observability Integrations
|
||||
|
||||
| Integration | Supports | Description |
|
||||
| -------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [SDK](/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
|
||||
| [OpenAI](/docs/integrations/openai) | Python, JS/TS | Automated instrumentation using drop-in replacement of OpenAI SDK. |
|
||||
| [Langchain](/docs/integrations/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
|
||||
| [LlamaIndex](/docs/integrations/llama-index/get-started) | Python | Automated instrumentation via LlamaIndex callback system. |
|
||||
| [Haystack](/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
|
||||
| [LiteLLM](/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
|
||||
| [API](/docs/api) | | Directly call the public API. OpenAPI spec available. |
|
||||
| Integration | Supports | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [SDK](https://langfuse.com/docs/sdk) | Python, JS/TS | Manual instrumentation using the SDKs for full flexibility. |
|
||||
| [OpenAI](https://langfuse.com/docs/integrations/openai) | Python, JS/TS | Automated instrumentation using drop-in replacement of OpenAI SDK. |
|
||||
| [Langchain](https://langfuse.com/docs/integrations/langchain) | Python, JS/TS | Automated instrumentation by passing callback handler to Langchain application. |
|
||||
| [LlamaIndex](https://langfuse.com/docs/integrations/llama-index/get-started) | Python | Automated instrumentation via LlamaIndex callback system. |
|
||||
| [Haystack](https://langfuse.com/docs/integrations/haystack) | Python | Automated instrumentation via Haystack content tracing system. |
|
||||
| [LiteLLM](https://langfuse.com/docs/integrations/litellm) | Python, JS/TS (proxy only) | Use any LLM as a drop in replacement for GPT. Use Azure, OpenAI, Cohere, Anthropic, Ollama, VLLM, Sagemaker, HuggingFace, Replicate (100+ LLMs). |
|
||||
| [Vercel AI SDK](https://langfuse.com/docs/integrations/vercel-ai-sdk) | JS/TS | TypeScript toolkit designed to help developers build AI-powered applications with React, Next.js, Vue, Svelte, Node.js. |
|
||||
| [API](https://langfuse.com/docs/api) | | Directly call the public API. OpenAPI spec available. |
|
||||
|
||||
Packages that integrate with Langfuse:
|
||||
Packages integrated with Langfuse:
|
||||
|
||||
| Name | Description |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
|
||||
| [Mirascope](/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
|
||||
| [AI SDK by Vercel](/docs/sdk/typescript/example-vercel-ai) | Typescript SDK that makes streaming LLM outputs super easy. |
|
||||
| [Flowise](/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
| Name | Description |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Instructor](https://langfuse.com/docs/integrations/instructor) | Library to get structured LLM outputs (JSON, Pydantic) |
|
||||
| [Dify](https://langfuse.com/docs/integrations/dify) | Open source LLM app development platform with no-code builder. |
|
||||
| [Ollama](https://langfuse.com/docs/integrations/ollama) | Easily run open source LLMs on your own machine. |
|
||||
| [Mirascope](https://langfuse.com/docs/integrations/mirascope) | Python toolkit for building LLM applications. |
|
||||
| [Flowise](https://langfuse.com/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](https://langfuse.com/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
|
||||
## Questions and feedback
|
||||
|
||||
@@ -140,13 +139,13 @@ Packages that integrate with Langfuse:
|
||||
|
||||
- [Roadmap](https://langfuse.com/roadmap)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/idea
|
||||
- [Feature Requests](https://langfuse.com/ideas)
|
||||
|
||||
### Support and feedback
|
||||
|
||||
In order of preference the best way to communicate with us:
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions): Contribute [ideas](https://langfuse.com/idea) [support requests](https://github.com/orgs/langfuse/discussions/categories/support) and [report bugs](https://github.com/langfuse/langfuse/issues/new?labels=%F0%9F%90%9E%E2%9D%94+unconfirmed+bug&projects=&template=bug_report.yml&title=bug%3A+) (preferred as we create a permanent, indexed artifact for other community members)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions) (preferred): Contribute [ideas](https://langfuse.com/ideas), [support requests](https://langfuse.com/gh-support) and [report bugs](https://langfuse.com/issues)
|
||||
- [Discord](https://langfuse.com/discord): community support
|
||||
- Privately: contact at langfuse dot com
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ services:
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
- LANGFUSE_WORKER_HOST=${LANGFUSE_WORKER_HOST:-worker}
|
||||
- LANGFUSE_WORKER_PASSWORD=${LANGFUSE_WORKER_PASSWORD:-mybasicauthsecret}
|
||||
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
|
||||
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
|
||||
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
|
||||
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
|
||||
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
|
||||
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/public/health"]
|
||||
|
||||
@@ -14,6 +14,15 @@ services:
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
|
||||
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
|
||||
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
|
||||
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
|
||||
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
|
||||
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
|
||||
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
|
||||
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
|
||||
+3
-2
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.7.7",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.23.8"
|
||||
@@ -39,7 +40,7 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-watch": "^6.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
|
||||
@@ -31,10 +31,10 @@ service:
|
||||
docs: Optional filter for metrics where traces include all of these tags
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces on or after a certain datetime (ISO 8601)
|
||||
docs: Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces before a certain datetime (ISO 8601)
|
||||
docs: Optional filter to only include traces and observations before a certain datetime (ISO 8601)
|
||||
response: DailyMetrics
|
||||
types:
|
||||
DailyMetrics:
|
||||
|
||||
@@ -3,32 +3,22 @@ groups:
|
||||
local:
|
||||
generators:
|
||||
- name: fernapi/fern-openapi
|
||||
version: 0.0.28
|
||||
version: 0.0.31
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api
|
||||
config:
|
||||
namespaceExport: Langfuse
|
||||
allowCustomFetcher: true
|
||||
- name: fernapi/fern-python-sdk
|
||||
version: 1.1.0-rc2
|
||||
version: 2.16.0
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../generated/python
|
||||
config:
|
||||
client_class_name: FernLangfuse
|
||||
improved_imports: false
|
||||
inline_request_params: false
|
||||
pydantic_config:
|
||||
require_optional_fields: false
|
||||
use_str_enums: false
|
||||
- name: fernapi/fern-typescript-node-sdk
|
||||
version: 0.7.1
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../generated/typescript-server
|
||||
config:
|
||||
namespaceExport: Langfuse
|
||||
allowCustomFetcher: true
|
||||
- name: fernapi/fern-postman
|
||||
version: 0.0.45
|
||||
output:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"organization": "finto",
|
||||
"version": "0.30.3"
|
||||
"version": "0.43.7"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.78.0",
|
||||
"version": "2.83.1",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -33,7 +33,7 @@
|
||||
"braces": "3.0.3",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"release-it": "^17.3.0",
|
||||
"turbo": "^1.13.4"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"eslint-config-next": "^14.2.14",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -55,28 +55,29 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.627.0",
|
||||
"@aws-sdk/lib-storage": "^3.568.0",
|
||||
"@aws-sdk/lib-storage": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.554.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@langchain/anthropic": "^0.3.1",
|
||||
"@langchain/core": "^0.3.7",
|
||||
"@langchain/openai": "^0.3.0",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.12.10",
|
||||
"dd-trace": "^5.19.0",
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"langchain": "^0.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"nodemailer": "^6.9.15",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.14.2",
|
||||
@@ -86,26 +87,26 @@
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"kysely-codegen": "^0.11.0",
|
||||
"nodemon": "^3.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.17.0",
|
||||
"kysely-codegen": "^0.16.8",
|
||||
"nodemon": "^3.1.7",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^5.20.0",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-watch": "^6.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.3"
|
||||
"vitest": "^2.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "~18.2.79",
|
||||
|
||||
@@ -80,6 +80,7 @@ export type Account = {
|
||||
scope: string | null;
|
||||
id_token: string | null;
|
||||
session_state: string | null;
|
||||
refresh_token_expires_in: number | null;
|
||||
};
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
-- For OpenAI reasoning models, manually tokenizing output string does not account for reasoning tokens, thus would lead to incorrect cost
|
||||
-- So no manual tokenizer config is provided
|
||||
-- Reference: https://platform.openai.com/docs/guides/reasoning/how-reasoning-works
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
-- o1-preview
|
||||
('cm10ivcdp0000gix7lelmbw80', NULL, 'o1-preview', '(?i)^(o1-preview)$', NULL, 0.000015, 0.00006, NULL, 'TOKENS', NULL, NULL),
|
||||
|
||||
-- o1-preview-2024-09-12
|
||||
('cm10ivo130000n8x7qopcjjcg', NULL, 'o1-preview-2024-09-12', '(?i)^(o1-preview-2024-09-12)$', NULL, 0.000015, 0.00006, NULL, 'TOKENS', NULL, NULL),
|
||||
|
||||
-- o1-mini
|
||||
('cm10ivwo40000r1x7gg3syjq0', NULL, 'o1-mini', '(?i)^(o1-mini)$', NULL, 0.000003, 0.000012, NULL, 'TOKENS', NULL, NULL),
|
||||
|
||||
-- o1-mini-2024-09-12
|
||||
('cm10iw6p20000wgx7it1hlb22', NULL, 'o1-mini-2024-09-12', '(?i)^(o1-mini-2024-09-12)$', NULL, 0.000003, 0.000012, NULL, 'TOKENS', NULL, NULL)
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "refresh_token_expires_in" INTEGER;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "traces_project_id_idx"; -- covered by traces_project_id_timestamp_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "datasets_project_id_idx"; -- covered by datasets_project_id_name_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "eval_templates_project_id_idx"; -- covered by eval_templates_project_id_id_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "job_configurations_project_id_idx"; -- covered by job_configurations_project_id_id_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "job_executions_project_id_idx"; -- covered by job_executions_project_id_id_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "llm_api_keys_project_id_provider_idx"; -- covered by llm_api_keys_project_id_provider_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "models_project_id_model_name_idx"; -- covered by models_project_id_model_name_start_date_unit_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "models_project_id_model_name_start_date_unit_idx"; -- covered by models_project_id_model_name_start_date_unit_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "observations_project_id_idx"; -- covered by observations_project_id_start_time_type_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "observations_trace_id_idx"; -- covered by observations_trace_id_project_id_start_time_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "observations_trace_id_project_id_idx"; -- covered by observations_trace_id_project_id_start_time_idx
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "organization_memberships_org_id_idx"; -- covered by organization_memberships_org_id_user_id_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "posthog_integrations_project_id_idx"; -- covered by posthog_integrations_pkey (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "prompts_project_id_idx"; -- covered by prompts_project_id_name_version_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "prompts_project_id_name_version_idx"; -- covered by prompts_project_id_name_version_key (unique)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- DropIndex
|
||||
-- Removes indexes that are covered by other indexes on the same table, e.g. (project_id) if (project_id, timestamp) exists.
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "scores_project_id_idx"; -- covered by scores_project_id_name_idx
|
||||
File diff suppressed because it is too large
Load Diff
+72
-36
@@ -13,40 +13,81 @@ import {
|
||||
import { DB } from ".";
|
||||
import { logger } from "./server";
|
||||
|
||||
// Instantiated according to the Prisma documentation
|
||||
// https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
|
||||
export class PrismaClientSingleton {
|
||||
private static instance: PrismaClient;
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
const client = new PrismaClient<
|
||||
Prisma.PrismaClientOptions,
|
||||
"warn" | "error" | "query"
|
||||
>({
|
||||
log: [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
{ emit: "event", level: "warn" },
|
||||
],
|
||||
});
|
||||
public static getInstance(): PrismaClient {
|
||||
if (PrismaClientSingleton.instance) {
|
||||
return PrismaClientSingleton.instance;
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
client.$on("query", (event) => {
|
||||
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
|
||||
const client = new PrismaClient<
|
||||
Prisma.PrismaClientOptions,
|
||||
"warn" | "error" | "query"
|
||||
>({
|
||||
log: [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
{ emit: "event", level: "warn" },
|
||||
],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
client.$on("query", (event) => {
|
||||
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
client.$on("warn", (event) => {
|
||||
logger.warn(`prisma:warn ${event.message}`);
|
||||
});
|
||||
|
||||
client.$on("error", (event) => {
|
||||
logger.error(`prisma:error ${event.message}`);
|
||||
});
|
||||
|
||||
PrismaClientSingleton.instance = client;
|
||||
|
||||
return PrismaClientSingleton.instance;
|
||||
}
|
||||
}
|
||||
|
||||
client.$on("warn", (event) => {
|
||||
logger.warn(`prisma:warn ${event.message}`);
|
||||
});
|
||||
export class KyselySingleton {
|
||||
private static instance: { $kysely: Kysely<DB> };
|
||||
|
||||
client.$on("error", (event) => {
|
||||
logger.error(`prisma:error ${event.message}`);
|
||||
});
|
||||
public static getInstance() {
|
||||
if (KyselySingleton.instance) {
|
||||
return KyselySingleton.instance;
|
||||
}
|
||||
|
||||
return client;
|
||||
};
|
||||
KyselySingleton.instance = PrismaClientSingleton.getInstance().$extends(
|
||||
kyselyExtension({
|
||||
kysely: (driver) =>
|
||||
new Kysely<DB>({
|
||||
dialect: {
|
||||
// This is where the magic happens!
|
||||
createDriver: () => driver,
|
||||
// Don't forget to customize these to match your database!
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
return prismaClient.$extends(
|
||||
return KyselySingleton.instance;
|
||||
}
|
||||
}
|
||||
|
||||
declare const globalThis: {
|
||||
prismaGlobal: PrismaClient | undefined;
|
||||
kyselyPrismaGlobal: { $kysely: Kysely<DB> } | undefined;
|
||||
} & typeof global;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
globalThis.prismaGlobal ??= new PrismaClient(); // regular instantiation
|
||||
globalThis.kyselyPrismaGlobal ??= globalThis.prismaGlobal.$extends(
|
||||
kyselyExtension({
|
||||
kysely: (driver) =>
|
||||
new Kysely<DB>({
|
||||
@@ -59,18 +100,13 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
|
||||
var kyselyPrisma: undefined | ReturnType<typeof kyselySingleton>;
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma ?? prismaClientSingleton();
|
||||
export const kyselyPrisma = globalThis.kyselyPrisma ?? kyselySingleton(prisma);
|
||||
export const prisma =
|
||||
globalThis.prismaGlobal ?? PrismaClientSingleton.getInstance();
|
||||
export const kyselyPrisma =
|
||||
globalThis.kyselyPrismaGlobal ?? KyselySingleton.getInstance();
|
||||
|
||||
export * from "@prisma/client";
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;
|
||||
|
||||
@@ -19,6 +19,8 @@ export enum BatchExportFileFormat {
|
||||
|
||||
export enum BatchExportTableName {
|
||||
Sessions = "sessions",
|
||||
Traces = "traces",
|
||||
Generations = "generations",
|
||||
}
|
||||
|
||||
export const exportOptions: Record<
|
||||
|
||||
@@ -6,6 +6,7 @@ export const RateLimitResource = z.enum([
|
||||
"public-api",
|
||||
"public-api-metrics",
|
||||
"prompts",
|
||||
"legacy-ingestion",
|
||||
]);
|
||||
|
||||
// result of the rate limit check.
|
||||
|
||||
@@ -52,7 +52,8 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
name: "Time To First Token (s)",
|
||||
id: "timeToFirstToken",
|
||||
type: "number",
|
||||
internal: 'o."completion_start_time" - o."start_time"',
|
||||
internal:
|
||||
'EXTRACT(EPOCH FROM (o."completion_start_time" - o."start_time"))',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
@@ -62,10 +63,10 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
internal: '"latency"',
|
||||
},
|
||||
{
|
||||
name: "Time Per Output Token (s)",
|
||||
id: "timePerOutputToken",
|
||||
name: "Tokens per second",
|
||||
id: "tokensPerSecond",
|
||||
type: "number",
|
||||
internal: '"latency" / o."completion_tokens"',
|
||||
internal: 'o."completion_tokens" / "latency"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ const arrayOperatorReplacements = {
|
||||
export function tableColumnsToSqlFilterAndPrefix(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames,
|
||||
table: TableNames
|
||||
): Prisma.Sql {
|
||||
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
|
||||
if (sql === Prisma.empty) {
|
||||
@@ -42,14 +42,14 @@ export function tableColumnsToSqlFilterAndPrefix(
|
||||
export function tableColumnsToSqlFilter(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames,
|
||||
table: TableNames
|
||||
): Prisma.Sql {
|
||||
const internalFilters = filters.map((filter) => {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
(c) =>
|
||||
// TODO: Only use id instead of name
|
||||
c.name === filter.column || c.id === filter.column,
|
||||
c.name === filter.column || c.id === filter.column
|
||||
);
|
||||
if (!col) {
|
||||
logger.error("Invalid filter column", filter.column);
|
||||
@@ -71,13 +71,13 @@ export function tableColumnsToSqlFilter(
|
||||
? Prisma.raw(
|
||||
arrayOperatorReplacements[
|
||||
filter.operator as keyof typeof arrayOperatorReplacements
|
||||
],
|
||||
]
|
||||
)
|
||||
: filter.operator in operatorReplacements
|
||||
? Prisma.raw(
|
||||
operatorReplacements[
|
||||
filter.operator as keyof typeof operatorReplacements
|
||||
],
|
||||
]
|
||||
)
|
||||
: Prisma.raw(filter.operator); //checked by zod
|
||||
|
||||
@@ -97,13 +97,13 @@ export function tableColumnsToSqlFilter(
|
||||
break;
|
||||
case "stringOptions":
|
||||
valuePrisma = Prisma.sql`(${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
filter.value.map((v) => Prisma.sql`${v}`)
|
||||
)})`;
|
||||
break;
|
||||
case "arrayOptions":
|
||||
valuePrisma = Prisma.sql`ARRAY[${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
", ",
|
||||
", "
|
||||
)}] `;
|
||||
break;
|
||||
|
||||
@@ -123,12 +123,12 @@ export function tableColumnsToSqlFilter(
|
||||
filter.type === "string" || filter.type === "stringObject"
|
||||
? [
|
||||
["contains", "does not contain", "ends with"].includes(
|
||||
filter.operator,
|
||||
filter.operator
|
||||
)
|
||||
? Prisma.raw("'%' || ")
|
||||
: Prisma.empty,
|
||||
["contains", "does not contain", "starts with"].includes(
|
||||
filter.operator,
|
||||
filter.operator
|
||||
)
|
||||
? Prisma.raw(" || '%'")
|
||||
: Prisma.empty,
|
||||
@@ -152,7 +152,7 @@ export function tableColumnsToSqlFilter(
|
||||
|
||||
const castValueToPostgresTypes = (
|
||||
column: ColumnDefinition,
|
||||
table: TableNames,
|
||||
table: TableNames
|
||||
) => {
|
||||
return column.name === "type" &&
|
||||
(table === "observations" ||
|
||||
@@ -168,7 +168,7 @@ const dateOperators = filterOperators["datetime"];
|
||||
export const datetimeFilterToPrismaSql = (
|
||||
safeColumn: string,
|
||||
operator: (typeof dateOperators)[number],
|
||||
value: Date,
|
||||
value: Date
|
||||
) => {
|
||||
if (!dateOperators.includes(operator)) {
|
||||
throw new Error("Invalid operator: " + operator);
|
||||
@@ -178,12 +178,12 @@ export const datetimeFilterToPrismaSql = (
|
||||
}
|
||||
|
||||
return Prisma.sql`AND ${Prisma.raw(safeColumn)} ${Prisma.raw(
|
||||
operator,
|
||||
operator
|
||||
)} ${value}::timestamp with time zone at time zone 'UTC'`;
|
||||
};
|
||||
|
||||
export const datetimeFilterToPrisma = (
|
||||
timestampFilter: z.infer<typeof timeFilter>,
|
||||
timestampFilter: z.infer<typeof timeFilter>
|
||||
) => {
|
||||
const prismaTimestampFilter =
|
||||
timestampFilter.operator === ">="
|
||||
|
||||
@@ -19,12 +19,13 @@ import { jsonSchema } from "../../../utils/zod";
|
||||
import { prisma } from "../../../db";
|
||||
import { LegacyIngestionAccessScope } from ".";
|
||||
import { logger } from "../../logger";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
}) => number | undefined
|
||||
) {
|
||||
this.event = event;
|
||||
this.calculateTokenDelegate = calculateTokenDelegate;
|
||||
@@ -48,7 +49,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
existingObservation: Observation | null,
|
||||
existingObservation: Omit<Observation, "input" | "output"> | null
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
@@ -78,7 +79,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
!existingObservation
|
||||
) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`Observation with id ${this.event.id} not found`,
|
||||
`Observation with id ${this.event.id} not found`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,11 +121,12 @@ export class ObservationProcessor implements EventProcessor {
|
||||
// Token counts
|
||||
const [newInputCount, newOutputCount] =
|
||||
"usage" in this.event.body
|
||||
? this.calculateTokenCounts(
|
||||
? await this.calculateTokenCounts(
|
||||
apiScope.projectId,
|
||||
this.event.body,
|
||||
this.calculateTokenDelegate,
|
||||
internalModel ?? undefined,
|
||||
existingObservation ?? undefined,
|
||||
existingObservation ?? undefined
|
||||
)
|
||||
: [undefined, undefined];
|
||||
|
||||
@@ -160,7 +162,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
|
||||
internalModel,
|
||||
userProvidedTokenCosts,
|
||||
tokenCounts,
|
||||
tokenCounts
|
||||
);
|
||||
|
||||
// merge metadata from existingObservation.metadata and metadata
|
||||
@@ -168,7 +170,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation?.metadata
|
||||
? jsonSchema.parse(existingObservation.metadata)
|
||||
: undefined,
|
||||
this.event.body.metadata ?? undefined,
|
||||
this.event.body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const prompt =
|
||||
@@ -311,7 +313,8 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
calculateTokenCounts(
|
||||
async calculateTokenCounts(
|
||||
projectId: string,
|
||||
body:
|
||||
| z.infer<typeof legacyObservationCreateEvent>["body"]
|
||||
| z.infer<typeof generationCreateEvent>["body"],
|
||||
@@ -320,29 +323,59 @@ export class ObservationProcessor implements EventProcessor {
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
model?: Model,
|
||||
existingObservation?: Observation,
|
||||
existingObservation?: Omit<Observation, "input" | "output">
|
||||
) {
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
((body.input || existingObservation?.input) && model && model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.input ?? existingObservation?.input,
|
||||
})
|
||||
: undefined);
|
||||
let newPromptTokens = body.usage?.input;
|
||||
if (newPromptTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.input) {
|
||||
newPromptTokens = calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.input,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`No input provided, trying to calculate for id: ${existingObservation?.id}`
|
||||
);
|
||||
const observationInput = await prisma.observation.findFirst({
|
||||
where: { id: existingObservation?.id, projectId: projectId },
|
||||
select: {
|
||||
input: true,
|
||||
},
|
||||
});
|
||||
|
||||
const newCompletionTokens =
|
||||
body.usage?.output ??
|
||||
((body.output || existingObservation?.output) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.output ?? existingObservation?.output,
|
||||
})
|
||||
: undefined);
|
||||
newPromptTokens = calculateTokenDelegate({
|
||||
model: model,
|
||||
text: observationInput?.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [newPromptTokens, newCompletionTokens];
|
||||
let newCompletionTokens = body.usage?.output;
|
||||
|
||||
if (newCompletionTokens === undefined && model && model.tokenizerId) {
|
||||
if (body.output) {
|
||||
newCompletionTokens = calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.output,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
`No output provided, trying to calculate for id: ${existingObservation?.id}`
|
||||
);
|
||||
const observationOutput = await prisma.observation.findFirst({
|
||||
where: { id: existingObservation?.id, projectId: projectId },
|
||||
select: {
|
||||
output: true,
|
||||
},
|
||||
});
|
||||
newCompletionTokens = calculateTokenDelegate({
|
||||
model: model,
|
||||
text: observationOutput?.output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [newPromptTokens ?? undefined, newCompletionTokens ?? undefined];
|
||||
}
|
||||
|
||||
static calculateTokenCosts(
|
||||
@@ -352,7 +385,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
outputCost?: Decimal | null;
|
||||
totalCost?: Decimal | null;
|
||||
},
|
||||
tokenCounts: { input?: number; output?: number; total?: number },
|
||||
tokenCounts: { input?: number; output?: number; total?: number }
|
||||
): {
|
||||
inputCost?: Decimal | null;
|
||||
outputCost?: Decimal | null;
|
||||
@@ -369,7 +402,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
totalCost:
|
||||
userProvidedCosts.totalCost ??
|
||||
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
|
||||
userProvidedCosts.outputCost ?? new Decimal(0),
|
||||
userProvidedCosts.outputCost ?? new Decimal(0)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -410,7 +443,43 @@ export class ObservationProcessor implements EventProcessor {
|
||||
|
||||
const existingObservation = this.event.body.id
|
||||
? await prisma.observation.findFirst({
|
||||
where: { id: this.event.body.id },
|
||||
select: {
|
||||
// do not select I/O to spare our db
|
||||
input: false,
|
||||
output: false,
|
||||
|
||||
id: true,
|
||||
traceId: true,
|
||||
projectId: true,
|
||||
type: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
parentObservationId: true,
|
||||
level: true,
|
||||
statusMessage: true,
|
||||
version: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
model: true,
|
||||
internalModelId: true,
|
||||
modelParameters: true,
|
||||
promptTokens: true,
|
||||
completionTokens: true,
|
||||
totalTokens: true,
|
||||
unit: true,
|
||||
inputCost: true,
|
||||
outputCost: true,
|
||||
totalCost: true,
|
||||
calculatedInputCost: true,
|
||||
calculatedOutputCost: true,
|
||||
calculatedTotalCost: true,
|
||||
completionStartTime: true,
|
||||
promptId: true,
|
||||
internalModel: true,
|
||||
},
|
||||
where: { id: this.event.body.id, projectId: apiScope.projectId },
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -419,7 +488,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation.projectId !== apiScope.projectId
|
||||
) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for observation creation ${existingObservation.projectId} `,
|
||||
`Access denied for observation creation ${existingObservation.projectId} `
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,7 +518,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -457,8 +526,8 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
logger.info(
|
||||
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`,
|
||||
logger.debug(
|
||||
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
@@ -469,7 +538,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation ${existingTrace.projectId}`,
|
||||
`Access denied for trace creation ${existingTrace.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -477,7 +546,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
: undefined,
|
||||
body.metadata ?? undefined,
|
||||
body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const mergedTags =
|
||||
@@ -555,12 +624,12 @@ export class ScoreProcessor implements EventProcessor {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`,
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
);
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -578,7 +647,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
});
|
||||
if (existingScore && existingScore.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation ${existingScore.projectId}`,
|
||||
`Access denied for score creation ${existingScore.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export const handleBatch = async (
|
||||
authCheck: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
|
||||
) => {
|
||||
logger.info(`handling ingestion ${events.length} events`);
|
||||
logger.debug(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function findModel(p: {
|
||||
unit?: string;
|
||||
startTime?: Date;
|
||||
};
|
||||
existingDbObservation?: Observation;
|
||||
existingDbObservation?: Omit<Observation, "input" | "output">;
|
||||
}): Promise<Model | null> {
|
||||
const { event, existingDbObservation } = p;
|
||||
// either get the model from the existing observation
|
||||
|
||||
@@ -12,13 +12,3 @@ Throughout our applications we want to use as much Otel as possible. This helps
|
||||
- `web` and `worker` have an instrumentation.ts file, which configures otel for the application.
|
||||
- For trpc, we use `@baselime/trpc-opentelemetry-middleware` to enrich spans with trpc inputs and outputs.
|
||||
- When building adding new infrastructure, we should search for auto instumentations for our code base.
|
||||
|
||||
## Library support
|
||||
|
||||
- `dd-trace` has a direct dependency of `@opentelemetry/api` version < `1.9` [GH](https://github.com/DataDog/dd-trace-js/blob/ed9b0b30f7b0283579a9bf8c18e1f9deab18fecf/package.json#L81).
|
||||
- `@prisma/instrumentation` requires at least `@opentelemetry/api` version `1.8` [GH](https://github.com/prisma/prisma/blob/d780290b13754420abcfa5d7592f02049c6cc005/packages/instrumentation/package.json#L25)
|
||||
- We need to ensure that all other libraries we use are compatible with `@opentelemetry/api` version `1.8`. If we have a package using a later version, this will break the instrumentation and crash the container. We can check the versions with the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter=web why @opentelemetry/api
|
||||
```
|
||||
|
||||
@@ -3,54 +3,42 @@ import * as dd from "dd-trace";
|
||||
|
||||
// type CallbackFn<T> = () => T;
|
||||
|
||||
export type TCarrier = {
|
||||
traceparent?: string;
|
||||
tracestate?: string;
|
||||
};
|
||||
|
||||
export type SpanCtx = {
|
||||
name: string;
|
||||
spanKind?: opentelemetry.SpanKind; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#spankind
|
||||
rootSpan?: boolean; // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/overview.md#traces
|
||||
traceScope?: string;
|
||||
traceContext?: TCarrier;
|
||||
};
|
||||
|
||||
type AsyncCallbackFn<T> = () => Promise<T>;
|
||||
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
|
||||
|
||||
export async function instrumentAsync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: AsyncCallbackFn<T>
|
||||
callback: AsyncCallbackFn<T>,
|
||||
): Promise<T> {
|
||||
return await getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await callback();
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
const activeContext = ctx.traceContext
|
||||
? opentelemetry.propagation.extract(
|
||||
opentelemetry.context.active(),
|
||||
ctx.traceContext,
|
||||
)
|
||||
: opentelemetry.context.active();
|
||||
|
||||
type SyncCallbackFn<T> = () => T;
|
||||
|
||||
export function instrumentSync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: SyncCallbackFn<T>
|
||||
): T {
|
||||
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
(span) => {
|
||||
activeContext,
|
||||
async (span) => {
|
||||
try {
|
||||
const result = callback();
|
||||
const result = await callback(span);
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
@@ -58,7 +46,41 @@ export function instrumentSync<T>(
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type SyncCallbackFn<T> = (span: opentelemetry.Span) => T;
|
||||
|
||||
export function instrumentSync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: SyncCallbackFn<T>,
|
||||
): T {
|
||||
const activeContext = ctx.traceContext
|
||||
? opentelemetry.propagation.extract(
|
||||
opentelemetry.context.active(),
|
||||
ctx.traceContext,
|
||||
)
|
||||
: opentelemetry.context.active();
|
||||
|
||||
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
activeContext,
|
||||
(span) => {
|
||||
try {
|
||||
const result = callback(span);
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +89,7 @@ export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
|
||||
export const traceException = (
|
||||
ex: unknown,
|
||||
span?: opentelemetry.Span,
|
||||
code?: string
|
||||
code?: string,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
@@ -115,7 +137,7 @@ export const traceException = (
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: { userId?: string; projectId?: string; email?: string },
|
||||
span?: opentelemetry.Span
|
||||
span?: opentelemetry.Span,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
@@ -138,7 +160,7 @@ export const recordGauge = (
|
||||
| {
|
||||
[tag: string]: string | number;
|
||||
}
|
||||
| undefined
|
||||
| undefined,
|
||||
) => {
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
@@ -146,7 +168,7 @@ export const recordGauge = (
|
||||
export const recordIncrement = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
dd.dogstatsd.increment(stat, value, tags);
|
||||
};
|
||||
@@ -154,7 +176,19 @@ export const recordIncrement = (
|
||||
export const recordHistogram = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
dd.dogstatsd.histogram(stat, value, tags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a queue name to the matching datadog metric name.
|
||||
* Consumer only needs to append the relevant suffix.
|
||||
*
|
||||
* Example: `legacy-ingestion-queue` -> `langfuse.queue.legacy_ingestion`
|
||||
*/
|
||||
export const convertQueueNameToMetricName = (queueName: string): string => {
|
||||
return (
|
||||
"langfuse.queue." + queueName.replace(/-/g, "_").replace(/_queue$/, "")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type ZodSchema } from "zod";
|
||||
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import {
|
||||
AIMessage,
|
||||
@@ -12,20 +14,12 @@ import {
|
||||
import { IterableReadableStream } from "@langchain/core/utils/stream";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatMessageRole,
|
||||
LLMFunctionCall,
|
||||
ModelParams,
|
||||
LLMAdapter,
|
||||
} from "./types";
|
||||
import zodToJsonSchema from "zod-to-json-schema";
|
||||
import { JsonOutputFunctionsParser } from "langchain/output_parsers";
|
||||
import { ChatMessage, ChatMessageRole, ModelParams, LLMAdapter } from "./types";
|
||||
|
||||
type LLMCompletionParams = {
|
||||
messages: ChatMessage[];
|
||||
modelParams: ModelParams;
|
||||
functionCall?: LLMFunctionCall;
|
||||
structuredOutputSchema?: ZodSchema;
|
||||
callbacks?: BaseCallbackHandler[];
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
@@ -51,7 +45,7 @@ export async function fetchLLMCompletion(
|
||||
export async function fetchLLMCompletion(
|
||||
params: LLMCompletionParams & {
|
||||
streaming: false;
|
||||
functionCall: LLMFunctionCall;
|
||||
structuredOutputSchema: ZodSchema;
|
||||
}
|
||||
): Promise<unknown>;
|
||||
|
||||
@@ -121,18 +115,41 @@ export async function fetchLLMCompletion(
|
||||
throw new Error("This model provider is not supported.");
|
||||
}
|
||||
|
||||
if (params.functionCall) {
|
||||
const functionCallingModel = chatModel.bind({
|
||||
functions: [
|
||||
{
|
||||
...params.functionCall,
|
||||
parameters: zodToJsonSchema(params.functionCall.parameters),
|
||||
},
|
||||
],
|
||||
function_call: { name: params.functionCall.name },
|
||||
});
|
||||
const outputParser = new JsonOutputFunctionsParser();
|
||||
return await functionCallingModel.pipe(outputParser).invoke(finalMessages);
|
||||
if (params.structuredOutputSchema) {
|
||||
return await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
|
||||
.withStructuredOutput(params.structuredOutputSchema)
|
||||
.invoke(finalMessages);
|
||||
}
|
||||
|
||||
/*
|
||||
Workaround OpenAI o1 while in beta:
|
||||
|
||||
This is a temporary workaround to avoid sending system messages to OpenAI's O1 models.
|
||||
O1 models do not support in beta:
|
||||
- system messages
|
||||
- top_p
|
||||
- max_tokens at all, one has to use max_completion_tokens instead
|
||||
- temperature different than 1
|
||||
|
||||
Reference: https://platform.openai.com/docs/guides/reasoning/beta-limitations
|
||||
*/
|
||||
if (modelParams.model.startsWith("o1-")) {
|
||||
return await new ChatOpenAI({
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
temperature: 1,
|
||||
maxTokens: undefined,
|
||||
topP: undefined,
|
||||
callbacks,
|
||||
maxRetries,
|
||||
configuration: {
|
||||
baseURL,
|
||||
},
|
||||
})
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(
|
||||
finalMessages.filter((message) => message._getType() !== "system")
|
||||
);
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from "zod";
|
||||
export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
|
||||
export type ChatMessage = {
|
||||
role: ChatMessageRole;
|
||||
role: ChatMessageRole | string; // Users may ingest any string as role via API/SDK
|
||||
content: string;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ export enum ChatMessageRole {
|
||||
Assistant = "assistant",
|
||||
}
|
||||
|
||||
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
|
||||
|
||||
export type ModelParams = {
|
||||
provider: string;
|
||||
adapter: LLMAdapter;
|
||||
@@ -52,6 +54,10 @@ export const openAIModels = [
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"o1-preview",
|
||||
"o1-preview-2024-09-12",
|
||||
"o1-mini",
|
||||
"o1-mini-2024-09-12",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-0613",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { env } from "../env";
|
||||
import winston from "winston";
|
||||
import { getCurrentSpan } from "./instrumentation";
|
||||
|
||||
const tracingFormat = function () {
|
||||
return winston.format((info) => {
|
||||
const span = getCurrentSpan();
|
||||
if (span) {
|
||||
const { spanId, traceId } = span.spanContext();
|
||||
const traceIdEnd = traceId.slice(traceId.length / 2);
|
||||
info["dd.trace_id"] = BigInt(`0x${traceIdEnd}`).toString();
|
||||
info["dd.span_id"] = BigInt(`0x${spanId}`).toString();
|
||||
info["trace_id"] = traceId;
|
||||
info["span_id"] = spanId;
|
||||
}
|
||||
return info;
|
||||
})();
|
||||
};
|
||||
|
||||
const getWinstonLogger = (
|
||||
nodeEnv: "development" | "production" | "test",
|
||||
@@ -18,6 +34,7 @@ const getWinstonLogger = (
|
||||
const jsonLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
tracingFormat(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ObservationView, Prisma } from "@prisma/client";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "../filterToPrisma";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
import { observationsTableCols } from "../../observationsTable";
|
||||
import { TableFilters } from "./types";
|
||||
|
||||
type AdditionalObservationFields = {
|
||||
traceName: string | null;
|
||||
promptName: string | null;
|
||||
promptVersion: string | null;
|
||||
traceTags: Array<string>;
|
||||
};
|
||||
|
||||
type FullObservation = AdditionalObservationFields & ObservationView;
|
||||
|
||||
export type FullObservations = Array<FullObservation>;
|
||||
|
||||
export type FullObservationsWithScores = Array<
|
||||
FullObservation & { scores?: Record<string, string[] | number[]> | null }
|
||||
>;
|
||||
|
||||
export type IOAndMetadataOmittedObservations = Array<
|
||||
Omit<ObservationView, "input" | "output" | "metadata"> &
|
||||
AdditionalObservationFields
|
||||
>;
|
||||
|
||||
export function parseGetAllGenerationsInput(filters: TableFilters) {
|
||||
const searchCondition = filters.searchQuery
|
||||
? Prisma.sql`AND (
|
||||
o."id" ILIKE ${`%${filters.searchQuery}%`} OR
|
||||
o."name" ILIKE ${`%${filters.searchQuery}%`} OR
|
||||
o."model" ILIKE ${`%${filters.searchQuery}%`} OR
|
||||
t."name" ILIKE ${`%${filters.searchQuery}%`}
|
||||
)`
|
||||
: Prisma.empty;
|
||||
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
filters.filter ?? [],
|
||||
observationsTableCols,
|
||||
"observations"
|
||||
);
|
||||
|
||||
const orderByCondition = orderByToPrismaSql(
|
||||
filters.orderBy,
|
||||
observationsTableCols
|
||||
);
|
||||
|
||||
// to improve query performance, add timeseries filter to observation queries as well
|
||||
const startTimeFilter = filters.filter?.find(
|
||||
(f) => f.column === "Start Time" && f.type === "datetime"
|
||||
);
|
||||
|
||||
const datetimeFilter =
|
||||
startTimeFilter && startTimeFilter.type === "datetime"
|
||||
? datetimeFilterToPrismaSql(
|
||||
"start_time",
|
||||
startTimeFilter.operator,
|
||||
startTimeFilter.value
|
||||
)
|
||||
: Prisma.empty;
|
||||
|
||||
return {
|
||||
searchCondition,
|
||||
filterCondition,
|
||||
orderByCondition,
|
||||
datetimeFilter,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGenerationsQuery({
|
||||
projectId,
|
||||
datetimeFilter = Prisma.empty,
|
||||
page,
|
||||
limit,
|
||||
searchCondition = Prisma.empty,
|
||||
filterCondition = Prisma.empty,
|
||||
orderByCondition = Prisma.empty,
|
||||
selectIOAndMetadata = false,
|
||||
selectScoreValues = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
datetimeFilter?: Prisma.Sql;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchCondition?: Prisma.Sql;
|
||||
filterCondition?: Prisma.Sql;
|
||||
orderByCondition?: Prisma.Sql;
|
||||
selectIOAndMetadata?: boolean;
|
||||
selectScoreValues?: boolean;
|
||||
}) {
|
||||
return Prisma.sql`
|
||||
WITH scores_avg AS (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
${selectScoreValues ? Prisma.sql`jsonb_object_agg(name::text, "values") AS "scores_values",` : Prisma.empty}
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS "scores_avg"
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
${selectScoreValues ? Prisma.sql`array_agg(COALESCE(string_value, value::text)) AS "values",` : Prisma.empty}
|
||||
avg(value) avg_value,
|
||||
comment
|
||||
FROM
|
||||
scores
|
||||
WHERE
|
||||
project_id = ${projectId}
|
||||
${selectScoreValues ? Prisma.empty : Prisma.sql`AND scores."data_type" IN ('NUMERIC', 'BOOLEAN')`}
|
||||
GROUP BY
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
comment
|
||||
ORDER BY
|
||||
trace_id
|
||||
) tmp
|
||||
GROUP BY
|
||||
trace_id,
|
||||
observation_id
|
||||
)
|
||||
SELECT
|
||||
${selectScoreValues ? Prisma.sql`s_avg."scores_values" AS "scores",` : Prisma.empty}
|
||||
o.id,
|
||||
o.name,
|
||||
o.model,
|
||||
o."modelParameters",
|
||||
o.start_time as "startTime",
|
||||
o.end_time as "endTime",
|
||||
${selectIOAndMetadata ? Prisma.sql`o.input, o.output, o.metadata,` : Prisma.empty}
|
||||
o.trace_id as "traceId",
|
||||
t.name as "traceName",
|
||||
o.completion_start_time as "completionStartTime",
|
||||
o.time_to_first_token as "timeToFirstToken",
|
||||
o.prompt_tokens as "promptTokens",
|
||||
o.completion_tokens as "completionTokens",
|
||||
o.total_tokens as "totalTokens",
|
||||
o.unit,
|
||||
o.level,
|
||||
o.status_message as "statusMessage",
|
||||
o.version,
|
||||
o.model_id as "modelId",
|
||||
o.input_price as "inputPrice",
|
||||
o.output_price as "outputPrice",
|
||||
o.total_price as "totalPrice",
|
||||
o.calculated_input_cost as "calculatedInputCost",
|
||||
o.calculated_output_cost as "calculatedOutputCost",
|
||||
o.calculated_total_cost as "calculatedTotalCost",
|
||||
o."latency",
|
||||
o.prompt_id as "promptId",
|
||||
p.name as "promptName",
|
||||
p.version as "promptVersion",
|
||||
t.tags as "traceTags"
|
||||
FROM observations_view o
|
||||
JOIN traces t ON t.id = o.trace_id AND t.project_id = ${projectId}
|
||||
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = t.id and s_avg.observation_id = o.id
|
||||
LEFT JOIN prompts p ON p.id = o.prompt_id AND p.project_id = ${projectId}
|
||||
WHERE
|
||||
o.project_id = ${projectId}
|
||||
AND o.type = 'GENERATION'
|
||||
${datetimeFilter}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
|
||||
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TableFilters } from "./types";
|
||||
import {
|
||||
datetimeFilterToPrismaSql,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "../filterToPrisma";
|
||||
import { tracesTableCols } from "../../tracesTable";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
|
||||
export function parseTraceAllFilters(input: TableFilters) {
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
input.filter ?? [],
|
||||
tracesTableCols,
|
||||
"traces"
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(input.orderBy, tracesTableCols);
|
||||
|
||||
// to improve query performance, add timeseries filter to observation queries as well
|
||||
const timeseriesFilter = input.filter?.find(
|
||||
(f) => f.column === "Timestamp" && f.type === "datetime"
|
||||
);
|
||||
|
||||
const observationTimeseriesFilter =
|
||||
timeseriesFilter && timeseriesFilter.type === "datetime"
|
||||
? datetimeFilterToPrismaSql(
|
||||
"start_time",
|
||||
timeseriesFilter.operator,
|
||||
timeseriesFilter.value
|
||||
)
|
||||
: Prisma.empty;
|
||||
|
||||
const searchCondition = input.searchQuery
|
||||
? Prisma.sql`AND (
|
||||
t."id" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
t."external_id" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
t."user_id" ILIKE ${`%${input.searchQuery}%`} OR
|
||||
t."name" ILIKE ${`%${input.searchQuery}%`}
|
||||
)`
|
||||
: Prisma.empty;
|
||||
|
||||
return {
|
||||
filterCondition,
|
||||
orderByCondition,
|
||||
observationTimeseriesFilter,
|
||||
searchCondition,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTracesQuery({
|
||||
select,
|
||||
projectId,
|
||||
observationTimeseriesFilter = Prisma.empty,
|
||||
page,
|
||||
limit,
|
||||
searchCondition = Prisma.empty,
|
||||
filterCondition = Prisma.empty,
|
||||
orderByCondition = Prisma.empty,
|
||||
selectScoreValues = false,
|
||||
}: {
|
||||
select: Prisma.Sql;
|
||||
projectId: string;
|
||||
observationTimeseriesFilter?: Prisma.Sql;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchCondition?: Prisma.Sql;
|
||||
filterCondition?: Prisma.Sql;
|
||||
orderByCondition?: Prisma.Sql;
|
||||
selectScoreValues?: boolean;
|
||||
}) {
|
||||
return Prisma.sql`
|
||||
SELECT
|
||||
${select}
|
||||
FROM
|
||||
"traces" AS t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
SUM(prompt_tokens) AS "promptTokens",
|
||||
SUM(completion_tokens) AS "completionTokens",
|
||||
SUM(total_tokens) AS "totalTokens",
|
||||
SUM(calculated_total_cost) AS "calculatedTotalCost",
|
||||
SUM(calculated_input_cost) AS "calculatedInputCost",
|
||||
SUM(calculated_output_cost) AS "calculatedOutputCost"
|
||||
FROM
|
||||
"observations_view"
|
||||
WHERE
|
||||
trace_id = t.id
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
) AS generation_metrics ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) AS "observationCount",
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX("end_time"), MAX("start_time"))) - EXTRACT(EPOCH FROM MIN("start_time"))::double precision AS "latency",
|
||||
COALESCE(
|
||||
MAX(CASE WHEN level = 'ERROR' THEN 'ERROR' END),
|
||||
MAX(CASE WHEN level = 'WARNING' THEN 'WARNING' END),
|
||||
MAX(CASE WHEN level = 'DEFAULT' THEN 'DEFAULT' END),
|
||||
'DEBUG'
|
||||
) AS "level"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
trace_id = t.id
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
) AS observation_metrics ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
${selectScoreValues ? Prisma.sql`jsonb_object_agg(name::text, "values") AS "scores_values",` : Prisma.empty}
|
||||
jsonb_object_agg(name::text, avg_value::double precision) AS "scores_avg"
|
||||
FROM (
|
||||
SELECT
|
||||
name,
|
||||
${selectScoreValues ? Prisma.sql`array_agg(COALESCE(string_value, value::text)) AS "values",` : Prisma.empty}
|
||||
AVG(value) avg_value
|
||||
FROM
|
||||
scores
|
||||
WHERE
|
||||
trace_id = t.id
|
||||
AND t."project_id" = ${projectId}
|
||||
${selectScoreValues ? Prisma.empty : Prisma.sql`AND scores."data_type" IN ('NUMERIC', 'BOOLEAN')`}
|
||||
GROUP BY
|
||||
name
|
||||
) tmp
|
||||
) AS s_avg ON true
|
||||
WHERE
|
||||
t."project_id" = ${projectId}
|
||||
${searchCondition}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
|
||||
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
|
||||
`;
|
||||
}
|
||||
@@ -1 +1,9 @@
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
export { createTracesQuery, parseTraceAllFilters } from "./createTracesQuery";
|
||||
export {
|
||||
createGenerationsQuery,
|
||||
parseGetAllGenerationsInput,
|
||||
type FullObservations,
|
||||
type FullObservationsWithScores,
|
||||
type IOAndMetadataOmittedObservations,
|
||||
} from "./createGenerationsQuery";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import z from "zod";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { optionalPaginationZod } from "../../utils/zod";
|
||||
|
||||
const TableFilterSchema = z.object({
|
||||
projectId: z.string(),
|
||||
filter: z.array(singleFilter).nullish(),
|
||||
searchQuery: z.string().nullish(),
|
||||
orderBy: orderBy,
|
||||
...optionalPaginationZod,
|
||||
});
|
||||
|
||||
export type TableFilters = z.infer<typeof TableFilterSchema>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ingestionBatchEvent } from ".";
|
||||
import { ingestionBatchEvent, TCarrier } from ".";
|
||||
|
||||
export enum EventName {
|
||||
TraceUpsert = "TraceUpsert",
|
||||
@@ -80,23 +80,27 @@ export type TQueueJobTypes = {
|
||||
id: string;
|
||||
payload: TraceUpsertEventType;
|
||||
name: QueueJobs.TraceUpsert;
|
||||
_tracecontext?: TCarrier;
|
||||
};
|
||||
[QueueName.EvaluationExecution]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: EvalExecutionEventType;
|
||||
name: QueueJobs.EvaluationExecution;
|
||||
_tracecontext?: TCarrier;
|
||||
};
|
||||
[QueueName.BatchExport]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: BatchExportJobType;
|
||||
name: QueueJobs.BatchExportJob;
|
||||
_tracecontext?: TCarrier;
|
||||
};
|
||||
[QueueName.LegacyIngestionQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: LegacyIngestionEventType;
|
||||
name: QueueJobs.LegacyIngestionJob;
|
||||
_tracecontext?: TCarrier;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,8 +15,12 @@ export const getBatchExportQueue = () => {
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
removeOnFail: 10_000,
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -18,9 +18,13 @@ export const getIngestionFlushQueue = () => {
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1_000,
|
||||
removeOnFail: 100_000,
|
||||
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
|
||||
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -21,8 +21,12 @@ export class LegacyIngestionQueue {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -21,7 +21,12 @@ export const getTraceUpsertQueue = () => {
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1_000,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
@@ -30,7 +35,7 @@ export const getTraceUpsertQueue = () => {
|
||||
};
|
||||
|
||||
export function convertTraceUpsertEventsToRedisEvents(
|
||||
events: TraceUpsertEventType[]
|
||||
events: TraceUpsertEventType[],
|
||||
) {
|
||||
const uniqueTracesPerProject = events.reduce((acc, event) => {
|
||||
if (!acc.get(event.projectId)) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Readable } from "stream";
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { logger } from "../logger";
|
||||
@@ -16,20 +20,26 @@ export class S3StorageService {
|
||||
private bucketName: string;
|
||||
|
||||
constructor(params: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
accessKeyId: string | undefined;
|
||||
secretAccessKey: string | undefined;
|
||||
bucketName: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
endpoint: string | undefined;
|
||||
region: string | undefined;
|
||||
}) {
|
||||
const { accessKeyId, secretAccessKey, bucketName, endpoint, region } =
|
||||
params;
|
||||
|
||||
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
|
||||
const credentials =
|
||||
accessKeyId !== undefined && secretAccessKey !== undefined
|
||||
? {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
this.client = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
credentials,
|
||||
endpoint,
|
||||
region,
|
||||
});
|
||||
@@ -57,11 +67,27 @@ export class S3StorageService {
|
||||
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
logger.error(`Failed to upload file to ${fileName}`, err);
|
||||
throw new Error("Failed to upload to S3 or generate signed URL");
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadJson(path: string, body: Record<string, unknown>) {
|
||||
const putCommand = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: path,
|
||||
Body: JSON.stringify(body),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
|
||||
try {
|
||||
await this.client.send(putCommand);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to upload JSON to S3 ${path}`, err);
|
||||
throw Error("Failed to upload JSON to S3");
|
||||
}
|
||||
}
|
||||
|
||||
private async getSignedUrl(
|
||||
fileName: string,
|
||||
ttlSeconds: number,
|
||||
@@ -77,6 +103,7 @@ export class S3StorageService {
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
|
||||
throw Error("Failed to generate signed URL");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -19,7 +19,6 @@ interface BatchExportSuccessTemplateProps {
|
||||
receiverEmail: string;
|
||||
downloadLink: string;
|
||||
batchExportName: string;
|
||||
expiresInHours: number;
|
||||
}
|
||||
|
||||
export const BatchExportSuccessEmailTemplate = ({
|
||||
@@ -27,7 +26,6 @@ export const BatchExportSuccessEmailTemplate = ({
|
||||
downloadLink,
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
}: BatchExportSuccessTemplateProps) => {
|
||||
const previewText = `Download your data export from Langfuse`;
|
||||
|
||||
@@ -56,8 +54,11 @@ export const BatchExportSuccessEmailTemplate = ({
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
Your data export{" "}
|
||||
<span className="font-mono">{batchExportName}</span> is ready to
|
||||
download. The download link will expire in{" "}
|
||||
<strong>{expiresInHours} hours</strong>.
|
||||
download. The download link is valid for a few hours.
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
Please note data exports do not reflect custom column ordering or
|
||||
visibility.
|
||||
</Text>
|
||||
<Section className="mb-4 mt-8 text-center">
|
||||
<Button
|
||||
|
||||
+1
-4
@@ -13,7 +13,6 @@ type SendBatchExportSuccessParams = {
|
||||
downloadLink: string;
|
||||
userName: string;
|
||||
batchExportName: string;
|
||||
expiresInHours: number;
|
||||
};
|
||||
|
||||
export const sendBatchExportSuccessEmail = async ({
|
||||
@@ -22,7 +21,6 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
downloadLink,
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
}: SendBatchExportSuccessParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
logger.error("Missing environment variables for sending email.");
|
||||
@@ -37,8 +35,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
downloadLink,
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
logger.error(
|
||||
"Missing environment variables for sending membership invitation email.",
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
const authUrl = getAuthURL();
|
||||
if (!authUrl) {
|
||||
logger.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
|
||||
+4
-3
@@ -56,8 +56,9 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-center text-xs leading-6 text-[#666666]">
|
||||
If you do not want to change your password or didn't request
|
||||
a reset, you can ignore and delete this email.
|
||||
The link is valid for 10 minutes. If you do not want to change
|
||||
your password or didn't request a reset, you can ignore and
|
||||
delete this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
@@ -76,7 +77,7 @@ export async function sendResetPasswordVerificationRequest(
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Forgot your password?`,
|
||||
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nIf you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
|
||||
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nThe link is valid for 10 minutes. If you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Transform, type TransformCallback } from "stream";
|
||||
import { stringify } from "./stringify";
|
||||
|
||||
const DELIMITER = ",";
|
||||
|
||||
export function transformStreamToCsv(): Transform {
|
||||
let isFirstChunk = true;
|
||||
let headers: string[] = [];
|
||||
@@ -15,15 +17,19 @@ export function transformStreamToCsv(): Transform {
|
||||
if (isFirstChunk) {
|
||||
// Extract headers from the first object
|
||||
headers = Object.keys(row);
|
||||
this.push(headers.join(",") + "\n");
|
||||
this.push(headers.join(DELIMITER) + "\n");
|
||||
isFirstChunk = false;
|
||||
}
|
||||
|
||||
// Convert the object to a CSV line and push it
|
||||
const csvRow = headers.map((header) => {
|
||||
const field = row[header];
|
||||
const str = stringify(field);
|
||||
let str = stringify(field);
|
||||
|
||||
// escape and format fields that contain commas
|
||||
if (str.includes(",")) {
|
||||
str = `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
if (str.startsWith('"') && str.endsWith('"')) {
|
||||
return str;
|
||||
} else {
|
||||
@@ -31,7 +37,7 @@ export function transformStreamToCsv(): Transform {
|
||||
}
|
||||
});
|
||||
|
||||
this.push(csvRow.join(",") + "\n");
|
||||
this.push(csvRow.join(DELIMITER) + "\n");
|
||||
|
||||
callback();
|
||||
},
|
||||
|
||||
@@ -78,28 +78,28 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
name: "Input Tokens",
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."promptTokens"',
|
||||
internal: 'generation_metrics."promptTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."completionTokens"',
|
||||
internal: 'generation_metrics."completionTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
internal: 'generation_metrics."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
id: "usage",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
internal: 'generation_metrics."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
name: "Latency (s)",
|
||||
id: "latency",
|
||||
type: "number",
|
||||
internal: "tl.latency",
|
||||
internal: "observation_metrics.latency",
|
||||
},
|
||||
{
|
||||
name: "Input Cost ($)",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type z } from "zod";
|
||||
import { singleFilter } from "./interfaces/filters";
|
||||
import { singleFilter, timeFilter } from "./interfaces/filters";
|
||||
|
||||
// to be sent to the server
|
||||
export type TimeFilter = z.infer<typeof timeFilter>;
|
||||
export type FilterCondition = z.infer<typeof singleFilter>;
|
||||
export type FilterState = FilterCondition[];
|
||||
|
||||
|
||||
Generated
+3456
-3944
File diff suppressed because it is too large
Load Diff
+38
-13
@@ -33,21 +33,43 @@ ENV DOCKER_BUILD 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
# pass public variables in build step
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
ARG NEXT_PUBLIC_DEMO_ORG_ID
|
||||
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
|
||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
|
||||
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
ENV NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
ARG NEXT_PUBLIC_DEMO_ORG_ID
|
||||
ENV NEXT_PUBLIC_DEMO_ORG_ID=$NEXT_PUBLIC_DEMO_ORG_ID
|
||||
ARG NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
ENV NEXT_PUBLIC_DEMO_PROJECT_ID=$NEXT_PUBLIC_DEMO_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_SIGN_UP_DISABLED
|
||||
ENV NEXT_PUBLIC_SIGN_UP_DISABLED=$NEXT_PUBLIC_SIGN_UP_DISABLED
|
||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
ENV NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE=$NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
|
||||
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_BASE_PATH
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
|
||||
# Sentry already needs to be set on build time to upload sourcemaps
|
||||
# This must not be set for OSS releases as we would share our Sentry secret.
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
ARG SENTRY_ORG
|
||||
ENV SENTRY_ORG=$SENTRY_ORG
|
||||
ARG SENTRY_PROJECT
|
||||
ENV SENTRY_PROJECT=$SENTRY_PROJECT
|
||||
|
||||
# Accept build id as NEXT_PUBLIC_BUILD_ID
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
|
||||
ENV NEXT_PUBLIC_BUILD_ID=$NEXT_PUBLIC_BUILD_ID
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
@@ -60,6 +82,9 @@ RUN rm -f ./web/src/middleware.ts
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
# set the CI flag to true to get CI specific logs
|
||||
ENV CI true
|
||||
|
||||
|
||||
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
|
||||
|
||||
@@ -81,7 +106,7 @@ RUN apk add --no-cache dumb-init
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma@5.13.0
|
||||
RUN npm install -g --no-package-lock --no-save prisma@5.20.0
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
|
||||
|
||||
+6
-5
@@ -11,8 +11,8 @@ import { env } from "./src/env.mjs";
|
||||
* img-src https to allow loading images from SSO providers
|
||||
*/
|
||||
const cspHeader = `
|
||||
default-src 'self' https://*.langfuse.com https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io wss://*.crisp.chat https://*.crisp.chat;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://client.crisp.chat;
|
||||
img-src 'self' https: blob: data: https://client.crisp.chat https://image.crisp.chat https://storage.crisp.chat;
|
||||
font-src 'self' https://client.crisp.chat;
|
||||
@@ -22,7 +22,7 @@ const cspHeader = `
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
connect-src 'self' https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://ph.langfuse.com;
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io;
|
||||
media-src 'self' https://client.crisp.chat;
|
||||
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
|
||||
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
|
||||
@@ -50,11 +50,12 @@ const nextConfig = {
|
||||
instrumentationHook: true,
|
||||
serverComponentsExternalPackages: [
|
||||
"dd-trace",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/api",
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq",
|
||||
],
|
||||
},
|
||||
poweredByHeader: false,
|
||||
basePath: env.NEXT_PUBLIC_BASE_PATH,
|
||||
|
||||
/**
|
||||
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
|
||||
@@ -176,7 +177,7 @@ export default withSentryConfig(nextConfig, {
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
// tunnelRoute: "/api/monitoring-tunnel",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
+59
-56
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.78.0",
|
||||
"version": "2.83.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -23,76 +23,79 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.1",
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.2",
|
||||
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "1.7.18",
|
||||
"@headlessui/tailwindcss": "0.2.1",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@langchain/anthropic": "^0.3.1",
|
||||
"@langchain/core": "^0.3.7",
|
||||
"@langchain/openai": "^0.3.0",
|
||||
"@langfuse/ee": "workspace:*",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.6.2",
|
||||
"@mui/x-tree-view": "^7.19.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.25.1",
|
||||
"@opentelemetry/core": "^1.14.0",
|
||||
"@opentelemetry/instrumentation": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.26.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.53.0",
|
||||
"@opentelemetry/instrumentation": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-aws-sdk": "^0.44.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.43.0",
|
||||
"@opentelemetry/instrumentation-winston": "^0.40.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-metrics": "1.23.0",
|
||||
"@opentelemetry/sdk-node": "^0.50.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@opentelemetry/resource-detector-aws": "^1.6.1",
|
||||
"@opentelemetry/resource-detector-container": "^0.4.1",
|
||||
"@opentelemetry/resources": "^1.26.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.26.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.26.0",
|
||||
"@prisma/instrumentation": "^5.20.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "8.26.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@sentry/nextjs": "^8.33.1",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.11.8",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tremor/react": "3.16.2",
|
||||
"@trpc/client": "^10.45.0",
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.0",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.23.5",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"ai": "^3.4.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.12.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -101,30 +104,30 @@
|
||||
"core-js": "^3.38.1",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.21.0",
|
||||
"dd-trace": "^5.23.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.5",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"js-tiktoken": "^1.0.15",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"langchain": "^0.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.436.0",
|
||||
"lucide-react": "^0.447.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-query-params": "^5.0.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"posthog-js": "^1.154.6",
|
||||
"posthog-js": "^1.161.3",
|
||||
"posthog-node": "^3.6.3",
|
||||
"prexit": "^2.2.0",
|
||||
"prisma": "^5.13.0",
|
||||
"prisma": "^5.20.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
@@ -145,8 +148,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jedmao/location": "^3.0.0",
|
||||
"@mermaid-js/mermaid-cli": "^10.7.0",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@mermaid-js/mermaid-cli": "^11.2.0",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
@@ -155,7 +158,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
@@ -171,12 +174,12 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.0",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
@@ -184,6 +187,6 @@
|
||||
"initVersion": "7.13.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"crisp-sdk-web": "^1.0.21"
|
||||
"crisp-sdk-web": "^1.0.25"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: &ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -141,7 +141,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/dataset-items/{id}:
|
||||
get:
|
||||
description: Get a dataset item
|
||||
@@ -186,7 +187,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/dataset-run-items:
|
||||
post:
|
||||
description: Create a dataset run item
|
||||
@@ -226,7 +228,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -286,7 +289,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
post:
|
||||
description: Create a dataset
|
||||
operationId: datasets_create
|
||||
@@ -325,7 +329,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -376,7 +381,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/datasets/{datasetName}/runs/{runName}:
|
||||
get:
|
||||
description: Get a dataset run and its items
|
||||
@@ -426,7 +432,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/datasets/{datasetName}/runs:
|
||||
get:
|
||||
description: Get dataset runs
|
||||
@@ -485,7 +492,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/health:
|
||||
get:
|
||||
description: Check health of API and database
|
||||
@@ -580,7 +588,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -649,8 +658,8 @@ paths:
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include traces on or after a certain
|
||||
datetime (ISO 8601)
|
||||
Optional filter to only include traces and observations on or after
|
||||
a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -659,8 +668,8 @@ paths:
|
||||
- name: toTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include traces before a certain datetime
|
||||
(ISO 8601)
|
||||
Optional filter to only include traces and observations before a
|
||||
certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -698,7 +707,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/models:
|
||||
post:
|
||||
description: Create a model
|
||||
@@ -738,7 +748,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -797,7 +808,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/models/{id}:
|
||||
get:
|
||||
description: Get a model
|
||||
@@ -842,7 +854,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
delete:
|
||||
description: >-
|
||||
Delete a model. Cannot delete models managed by Langfuse. You can create
|
||||
@@ -885,7 +898,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/observations/{observationId}:
|
||||
get:
|
||||
description: Get a observation
|
||||
@@ -933,7 +947,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/observations:
|
||||
get:
|
||||
description: Get a list of observations
|
||||
@@ -1046,7 +1061,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/projects:
|
||||
get:
|
||||
description: Get Project associated with API key
|
||||
@@ -1086,7 +1102,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/v2/prompts/{promptName}:
|
||||
get:
|
||||
description: Get a prompt
|
||||
@@ -1148,7 +1165,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/v2/prompts:
|
||||
get:
|
||||
description: Get a list of prompt names with versions and labels
|
||||
@@ -1240,7 +1258,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
post:
|
||||
description: Create a new version for the prompt with the given `name`
|
||||
operationId: prompts_create
|
||||
@@ -1279,7 +1298,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1327,7 +1347,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1388,7 +1409,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/score-configs/{configId}:
|
||||
get:
|
||||
description: Get a score config
|
||||
@@ -1434,7 +1456,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/scores:
|
||||
post:
|
||||
description: Create a score
|
||||
@@ -1474,7 +1497,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1612,7 +1636,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/scores/{scoreId}:
|
||||
get:
|
||||
description: Get a score
|
||||
@@ -1658,7 +1683,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
delete:
|
||||
description: Delete a score
|
||||
operationId: score_delete
|
||||
@@ -1699,7 +1725,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/sessions:
|
||||
get:
|
||||
description: Get sessions
|
||||
@@ -1775,7 +1802,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/sessions/{sessionId}:
|
||||
get:
|
||||
description: >-
|
||||
@@ -1824,7 +1852,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/traces/{traceId}:
|
||||
get:
|
||||
description: Get a specific trace
|
||||
@@ -1870,7 +1899,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/traces:
|
||||
get:
|
||||
description: Get list of traces
|
||||
@@ -1997,7 +2027,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
security:
|
||||
- BasicAuth: []
|
||||
components:
|
||||
schemas:
|
||||
Trace:
|
||||
@@ -2696,6 +2727,7 @@ components:
|
||||
to exact match, use `(?i)^modelname$`
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
description: Apply only to generations which are newer than this ISO date.
|
||||
unit:
|
||||
@@ -3508,6 +3540,7 @@ components:
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
countTraces:
|
||||
type: integer
|
||||
countObservations:
|
||||
@@ -3592,6 +3625,7 @@ components:
|
||||
to exact match, use `(?i)^modelname$`
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
description: Apply only to generations which are newer than this ISO date.
|
||||
unit:
|
||||
|
||||
@@ -516,12 +516,12 @@
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces on or after a certain datetime (ISO 8601)"
|
||||
"description": "Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces before a certain datetime (ISO 8601)"
|
||||
"description": "Optional filter to only include traces and observations before a certain datetime (ISO 8601)"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
|
||||
@@ -2,13 +2,15 @@ import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
||||
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [
|
||||
Sentry.replayIntegration(),
|
||||
// Sentry.debugIntegration(),
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
// Sentry.captureConsoleIntegration({
|
||||
// levels: ["error"],
|
||||
// }),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
@@ -16,24 +18,16 @@ Sentry.init({
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
: 0,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
|
||||
beforeSend(event, _hint) {
|
||||
// Check if it is an exception, and if so, show the report dialog
|
||||
if (event.exception) {
|
||||
Sentry.showReportDialog({ eventId: event.event_id });
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
||||
// ...
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
|
||||
@@ -97,3 +97,44 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
|
||||
|
||||
await expect(page).toHaveURL(promptUrl);
|
||||
});
|
||||
|
||||
test("Unauthenticated user should not be redirected to non-relative URLs after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
const nonRelativeUrl = "https://example.com";
|
||||
await page.goto(
|
||||
`/auth/sign-in?targetPath=${encodeURIComponent(nonRelativeUrl)}`,
|
||||
);
|
||||
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expect to be redirected to the home page, not the non-relative URL
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Verify we're logged in
|
||||
await expect(page.getByRole("button", { name: /Demo User/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Unauthenticated user should be redirected to relative URL after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
const relativeUrl = "/support";
|
||||
await page.goto(
|
||||
`/auth/sign-in?targetPath=${encodeURIComponent(relativeUrl)}`,
|
||||
);
|
||||
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expect to be redirected to the relative URL
|
||||
await expect(page).toHaveURL(relativeUrl);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ describe("RateLimitService", () => {
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
expect(redis).toBeDefined();
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
|
||||
@@ -30,7 +30,17 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
props,
|
||||
) => {
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
const createExport = api.batchExport.create.useMutation();
|
||||
const createExport = api.batchExport.create.useMutation({
|
||||
onSettled: () => {
|
||||
setIsExporting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSuccessToast({
|
||||
title: "Export queued",
|
||||
description: "You will receive an email when the export is ready.",
|
||||
});
|
||||
},
|
||||
});
|
||||
const entitled = useHasOrgEntitlement("batch-export");
|
||||
const hasAccess = useHasProjectAccess({
|
||||
projectId: props.projectId,
|
||||
@@ -49,11 +59,6 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
orderBy: props.orderByState,
|
||||
},
|
||||
});
|
||||
setIsExporting(false);
|
||||
showSuccessToast({
|
||||
title: "Export queued",
|
||||
description: "You will receive an email when the export is ready.",
|
||||
});
|
||||
};
|
||||
|
||||
if (!entitled || !hasAccess) return null;
|
||||
|
||||
@@ -9,27 +9,40 @@ import type { MessagesContext } from "./types";
|
||||
|
||||
type ChatMessageProps = Pick<
|
||||
MessagesContext,
|
||||
"deleteMessage" | "updateMessage"
|
||||
"deleteMessage" | "updateMessage" | "availableRoles"
|
||||
> & { message: ChatMessageWithId };
|
||||
|
||||
export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
availableRoles,
|
||||
}) => {
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [textAreaRows, setTextAreaRows] = useState(1);
|
||||
const [roleIndex, setRoleIndex] = useState(1);
|
||||
|
||||
const toggleRole = () => {
|
||||
if (message.role === ChatMessageRole.System) return;
|
||||
|
||||
updateMessage(
|
||||
message.id,
|
||||
"role",
|
||||
message.role === ChatMessageRole.User
|
||||
? ChatMessageRole.Assistant
|
||||
: ChatMessageRole.User,
|
||||
);
|
||||
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
|
||||
if (!!availableRoles && Boolean(availableRoles.length)) {
|
||||
let randomRole = availableRoles[roleIndex % availableRoles.length];
|
||||
if (randomRole === message.role) {
|
||||
randomRole = availableRoles[(roleIndex + 1) % availableRoles.length];
|
||||
}
|
||||
updateMessage(message.id, "role", randomRole);
|
||||
setRoleIndex(roleIndex + 1);
|
||||
} else {
|
||||
// if user has not set custom roles, we toggle through default roles (assistant, user)
|
||||
updateMessage(
|
||||
message.id,
|
||||
"role",
|
||||
message.role === ChatMessageRole.User
|
||||
? ChatMessageRole.Assistant
|
||||
: ChatMessageRole.User,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -64,7 +77,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
id={message.id}
|
||||
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
|
||||
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
value={message.content}
|
||||
onChange={handleContentChange}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChatMessageRole, ChatMessageWithId } from "@langfuse/shared";
|
||||
|
||||
|
||||
export type MessagesContext = {
|
||||
messages: ChatMessageWithId[];
|
||||
addMessage: (role: ChatMessageRole, content?: string) => ChatMessageWithId;
|
||||
@@ -8,6 +7,7 @@ export type MessagesContext = {
|
||||
updateMessage: <Key extends keyof ChatMessageWithId>(
|
||||
id: string,
|
||||
key: Key,
|
||||
value: ChatMessageWithId[Key]
|
||||
value: ChatMessageWithId[Key],
|
||||
) => void;
|
||||
availableRoles?: string[]; // Only defined if user has extended default roles (ChatMessageRole) with custom roles via SDK/API
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import Link from "next/link";
|
||||
import { EnvLabel } from "./EnvLabel";
|
||||
import { VersionLabel } from "./VersionLabel";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
export const LangfuseIcon = ({
|
||||
size = 32,
|
||||
@@ -12,7 +15,7 @@ export const LangfuseIcon = ({
|
||||
}) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src="/icon.svg"
|
||||
src={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="Langfuse Icon"
|
||||
@@ -20,6 +23,52 @@ export const LangfuseIcon = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const LangfuseLogotypeOrCustomized = ({ size }: { size: "sm" | "xl" }) => {
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
if (uiCustomization?.logoLightModeHref && uiCustomization?.logoDarkModeHref) {
|
||||
// logo is a url, maximum aspect ratio of 1:3 needs to be supported according to docs
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={uiCustomization.logoLightModeHref}
|
||||
alt="Langfuse Logo"
|
||||
className={cn(
|
||||
"dark:hidden",
|
||||
size === "sm" ? "max-h-4 max-w-14" : "max-h-5 max-w-16",
|
||||
)}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={uiCustomization.logoDarkModeHref}
|
||||
alt="Langfuse Logo"
|
||||
className={cn(
|
||||
"hidden dark:block",
|
||||
size === "sm" ? "max-h-4 max-w-14" : "max-h-5 max-w-16",
|
||||
)}
|
||||
/>
|
||||
<PlusIcon size={size === "sm" ? 8 : 12} />
|
||||
<LangfuseIcon size={size === "sm" ? 16 : 20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<LangfuseIcon size={size === "sm" ? 16 : 20} />
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 font-mono font-semibold",
|
||||
size === "sm" ? "text-sm" : "text-xl",
|
||||
)}
|
||||
>
|
||||
Langfuse
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LangfuseLogo = ({
|
||||
className,
|
||||
size = "sm",
|
||||
@@ -30,26 +79,23 @@ export const LangfuseLogo = ({
|
||||
className?: string;
|
||||
version?: boolean;
|
||||
showEnvLabel?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap gap-4 lg:flex-col lg:items-start", className)}
|
||||
>
|
||||
{/* Environment Labeling for Langfuse Maintainers */}
|
||||
{showEnvLabel && <EnvLabel />}
|
||||
{/* Langfuse Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center">
|
||||
<LangfuseIcon size={size === "sm" ? 16 : 20} />
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 font-mono font-semibold",
|
||||
size === "sm" ? "text-sm" : "text-xl",
|
||||
)}
|
||||
>
|
||||
Langfuse
|
||||
</span>
|
||||
</Link>
|
||||
{version && <VersionLabel className="ml-2" />}
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-4 lg:flex-col lg:items-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Environment Labeling for Langfuse Maintainers */}
|
||||
{showEnvLabel && <EnvLabel />}
|
||||
{/* Langfuse Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center">
|
||||
<LangfuseLogotypeOrCustomized size={size} />
|
||||
</Link>
|
||||
{version && <VersionLabel className="ml-2" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@langfuse/shared";
|
||||
|
||||
import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
|
||||
import { FormDescription } from "@/src/components/ui/form";
|
||||
|
||||
export type ModelParamsContext = {
|
||||
modelParams: UIModelParams;
|
||||
@@ -29,6 +30,7 @@ export type ModelParamsContext = {
|
||||
) => void;
|
||||
setModelParamEnabled?: (key: keyof UIModelParams, enabled: boolean) => void;
|
||||
formDisabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<
|
||||
@@ -41,6 +43,7 @@ export const ModelParameters: React.FC<
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
modelParamsDescription,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
|
||||
@@ -74,7 +77,22 @@ export const ModelParameters: React.FC<
|
||||
value={modelParams.model.value}
|
||||
options={availableModels}
|
||||
updateModelParam={updateModelParamValue}
|
||||
modelParamsDescription={modelParamsDescription}
|
||||
/>
|
||||
{modelParams.model.value?.startsWith("o1-") ? (
|
||||
<p className="text-sm text-dark-yellow">
|
||||
For {modelParams.model.value}, the system message and the
|
||||
temperature, max_tokens and top_p setting are not supported while
|
||||
it is in beta.{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/docs/guides/reasoning/beta-limitations"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
More info ↗
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<ModelParamsSlider
|
||||
title="Temperature"
|
||||
modelParamsKey="temperature"
|
||||
@@ -128,6 +146,7 @@ type ModelParamsSelectProps = {
|
||||
options: string[];
|
||||
updateModelParam: ModelParamsContext["updateModelParamValue"];
|
||||
disabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
const ModelParamsSelect = ({
|
||||
title,
|
||||
@@ -136,6 +155,7 @@ const ModelParamsSelect = ({
|
||||
options,
|
||||
updateModelParam,
|
||||
disabled,
|
||||
modelParamsDescription,
|
||||
}: ModelParamsSelectProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -168,6 +188,9 @@ const ModelParamsSelect = ({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
<FormDescription>{modelParamsDescription}</FormDescription>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,13 +4,21 @@ export const FullScreenPage: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
mobile?: boolean;
|
||||
className?: string;
|
||||
lgHeight?: string;
|
||||
mobileHeight?: string;
|
||||
}>
|
||||
> = ({ children, mobile = true, className }) => {
|
||||
> = ({
|
||||
children,
|
||||
mobile = true,
|
||||
className,
|
||||
lgHeight = "lg:h-[calc(100dvh-1.5rem)]",
|
||||
mobileHeight = "h-[calc(100dvh-6rem)]",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden lg:h-[calc(100dvh-1.5rem)]",
|
||||
mobile && "h-[calc(100dvh-6rem)]",
|
||||
`flex flex-col overflow-hidden ${lgHeight}`,
|
||||
mobile && mobileHeight,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
createOrganizationRoute,
|
||||
createProjectRoute,
|
||||
} from "@/src/features/setup/setupRoutes";
|
||||
import { isCloudPlan, planLabels } from "@langfuse/shared";
|
||||
|
||||
export default function Header({
|
||||
level = "h2",
|
||||
@@ -170,8 +171,17 @@ const BreadcrumbComponent = ({
|
||||
<BreadcrumbList>
|
||||
{organization && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<DropdownMenuTrigger className="flex items-center gap-1 text-sm text-primary">
|
||||
{organization?.name ?? "Organization"}
|
||||
{isCloudPlan(organization?.plan) &&
|
||||
organization.id !== env.NEXT_PUBLIC_DEMO_ORG_ID && (
|
||||
<Badge
|
||||
className="ml-1 px-1 py-0 text-xs font-normal"
|
||||
variant="secondary"
|
||||
>
|
||||
{planLabels[organization.plan]}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
@@ -265,7 +275,7 @@ const BreadcrumbComponent = ({
|
||||
<Slash />
|
||||
</BreadcrumbSeparator>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<DropdownMenuTrigger className="flex items-center gap-1 text-primary">
|
||||
{project?.name ?? "Project"}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -33,14 +33,13 @@ import { EnvLabel } from "@/src/components/EnvLabel";
|
||||
import { useQueryProjectOrOrganization } from "@/src/features/projects/hooks";
|
||||
import { useOrgEntitlements } from "@/src/features/entitlements/hooks";
|
||||
import { useUiCustomization } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
await signOut({
|
||||
callbackUrl: "/auth/sign-in",
|
||||
});
|
||||
await signOut();
|
||||
};
|
||||
|
||||
const userNavigation = [
|
||||
@@ -164,6 +163,21 @@ export default function Layout(props: PropsWithChildren) {
|
||||
}))
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
route.organizationRbacScope !== undefined &&
|
||||
!cloudAdmin &&
|
||||
(!organization ||
|
||||
!hasOrganizationAccess({
|
||||
organizationId: organization.id,
|
||||
scope: route.organizationRbacScope,
|
||||
session: session.data,
|
||||
}))
|
||||
)
|
||||
return null;
|
||||
|
||||
// check show function
|
||||
if (route.show && !route.show({ organization: organization ?? undefined }))
|
||||
return null;
|
||||
|
||||
// apply to children as well
|
||||
const children: (NavigationItem | null)[] =
|
||||
@@ -238,13 +252,20 @@ export default function Layout(props: PropsWithChildren) {
|
||||
session.status === "authenticated" &&
|
||||
unauthenticatedPaths.includes(router.pathname)
|
||||
) {
|
||||
const targetPath = router.query.targetPath as string | undefined;
|
||||
const queryTargetPath = router.query.targetPath as string | undefined;
|
||||
|
||||
const sanitizedTargetPath = targetPath
|
||||
? DOMPurify.sanitize(targetPath)
|
||||
const sanitizedTargetPath = queryTargetPath
|
||||
? DOMPurify.sanitize(queryTargetPath)
|
||||
: undefined;
|
||||
|
||||
void router.replace(sanitizedTargetPath ?? "/");
|
||||
// only allow relative links
|
||||
const targetPath =
|
||||
sanitizedTargetPath?.startsWith("/") &&
|
||||
!sanitizedTargetPath.startsWith("//")
|
||||
? sanitizedTargetPath
|
||||
: "/";
|
||||
|
||||
void router.replace(targetPath);
|
||||
return <Spinner message="Redirecting" />;
|
||||
}
|
||||
|
||||
@@ -267,19 +288,19 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/apple-touch-icon.png`}
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
|
||||
/>
|
||||
</Head>
|
||||
<div>
|
||||
@@ -520,7 +541,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
|
||||
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
<div className="flex flex-1 flex-wrap gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Info className="h-4 w-4" />
|
||||
@@ -563,6 +584,8 @@ const MainNavigation: React.FC<{
|
||||
{},
|
||||
);
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
return (
|
||||
<li className={className}>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
@@ -580,34 +603,44 @@ const MainNavigation: React.FC<{
|
||||
onClick={onNavitemClick}
|
||||
target={item.newTab ? "_blank" : undefined}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-primary-accent"
|
||||
: "text-muted-foreground group-hover:text-primary-accent",
|
||||
"h-5 w-5 shrink-0",
|
||||
{item.pathname === "/" &&
|
||||
uiCustomization?.logoLightModeHref &&
|
||||
uiCustomization?.logoDarkModeHref ? (
|
||||
// override the default logo with the uiCustomization logo if the pathname is "/"
|
||||
<LangfuseLogo size="sm" version />
|
||||
) : (
|
||||
// default node for all other routes
|
||||
<>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-primary-accent"
|
||||
: "text-muted-foreground group-hover:text-primary-accent",
|
||||
"h-5 w-5 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
{item.label &&
|
||||
(typeof item.label === "string" ? (
|
||||
<span
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
// ReactNode
|
||||
item.label
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.name}
|
||||
{item.label &&
|
||||
(typeof item.label === "string" ? (
|
||||
<span
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
// ReactNode
|
||||
item.label
|
||||
))}
|
||||
</Link>
|
||||
) : item.children && item.children.length > 0 ? (
|
||||
<Disclosure
|
||||
|
||||
@@ -13,18 +13,23 @@ import {
|
||||
TerminalIcon,
|
||||
Lightbulb,
|
||||
Grid2X2,
|
||||
Sparkle,
|
||||
} from "lucide-react";
|
||||
import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { type ReactNode } from "react";
|
||||
import { VersionLabel } from "@/src/components/VersionLabel";
|
||||
import { type Entitlement } from "@/src/features/entitlements/constants/entitlements";
|
||||
import { type UiCustomizationOption } from "@/src/ee/features/ui-customization/useUiCustomization";
|
||||
import { type User } from "next-auth";
|
||||
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
|
||||
import { UsageTracker } from "@/src/ee/features/billing/components/UsageTracker";
|
||||
|
||||
export type Route = {
|
||||
name: string;
|
||||
featureFlag?: Flag;
|
||||
label?: string | ReactNode;
|
||||
projectRbacScope?: ProjectScope;
|
||||
organizationRbacScope?: OrganizationScope;
|
||||
icon?: LucideIcon | typeof LangfuseIcon; // ignored for nested routes
|
||||
pathname?: string; // link, ignored if children
|
||||
children?: Array<Route>; // folder
|
||||
@@ -32,6 +37,9 @@ export type Route = {
|
||||
newTab?: boolean; // open in new tab
|
||||
entitlement?: Entitlement; // entitlement required
|
||||
customizableHref?: UiCustomizationOption; // key of useUiCustomization object to use to replace the href
|
||||
show?: (p: {
|
||||
organization: User["organizations"][number] | undefined;
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
@@ -40,6 +48,7 @@ export const ROUTES: Route[] = [
|
||||
pathname: "/",
|
||||
icon: LangfuseIcon,
|
||||
label: <VersionLabel className="-ml-3" />,
|
||||
// node is overridden in layout.tsx if uiCustomization.logoLightModeHref and uiCustomization.logoDarkModeHref are set
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
@@ -125,6 +134,26 @@ export const ROUTES: Route[] = [
|
||||
pathname: `/project/[projectId]/datasets`,
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
name: "Upgrade",
|
||||
icon: Sparkle,
|
||||
pathname: "/project/[projectId]/settings/billing",
|
||||
bottom: true,
|
||||
entitlement: "cloud-billing",
|
||||
organizationRbacScope: "langfuseCloudBilling:CRUD",
|
||||
show: ({ organization }) => organization?.plan === "cloud:hobby",
|
||||
label: <UsageTracker />,
|
||||
},
|
||||
{
|
||||
name: "Upgrade",
|
||||
icon: Sparkle,
|
||||
pathname: "/organization/[organizationId]/settings/billing",
|
||||
bottom: true,
|
||||
entitlement: "cloud-billing",
|
||||
organizationRbacScope: "langfuseCloudBilling:CRUD",
|
||||
show: ({ organization }) => organization?.plan === "cloud:hobby",
|
||||
label: <UsageTracker />,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
pathname: "/project/[projectId]/settings",
|
||||
|
||||
@@ -93,7 +93,7 @@ const Base = (props: {
|
||||
"Loading.."
|
||||
) : props.isPublic ? (
|
||||
<div
|
||||
className="text-dark-green flex cursor-pointer items-center gap-1"
|
||||
className="flex cursor-pointer items-center gap-1 text-dark-green"
|
||||
onClick={() => copyUrl()}
|
||||
>
|
||||
{isCopied ? "Link copied ..." : "Public"}
|
||||
|
||||
@@ -196,7 +196,7 @@ const SessionIO = ({
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="col-span-2 grid grid-flow-row gap-2 p-0">
|
||||
<div className="col-span-2 flex flex-col gap-2 p-0">
|
||||
{!trace.data ? (
|
||||
<JsonSkeleton
|
||||
className="h-full w-full overflow-hidden px-2 py-1"
|
||||
|
||||
@@ -63,47 +63,35 @@ export function StarTraceToggle({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mutBookmarkTrace = api.traces.bookmark.useMutation({
|
||||
// Optimistic update
|
||||
// Tanstack docs: https://tanstack.com/query/v4/docs/react/guides/optimistic-updates
|
||||
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
onMutate: async (newBookmarkState) => {
|
||||
await utils.traces.all.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prev = utils.traces.all.getData(tracesFilter);
|
||||
const previousData = utils.traces.all.getData(tracesFilter);
|
||||
|
||||
return { prev };
|
||||
utils.traces.all.setData(tracesFilter, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
traces: old.traces.map((trace) =>
|
||||
trace.id === traceId
|
||||
? { ...trace, bookmarked: newBookmarkState.bookmarked }
|
||||
: trace,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (err, _newTodo, context) => {
|
||||
onError: (err, newBookmarkState, context) => {
|
||||
setIsLoading(false);
|
||||
// Rollback to the previous value if mutation fails
|
||||
trpcErrorToast(err);
|
||||
utils.traces.all.setData(tracesFilter, context?.prev);
|
||||
if (context?.previousData) {
|
||||
utils.traces.all.setData(tracesFilter, context.previousData);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
utils.traces.all.setData(
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return {
|
||||
traces: oldQueryData?.traces
|
||||
? oldQueryData.traces.map((trace) => {
|
||||
return {
|
||||
...trace,
|
||||
bookmarked:
|
||||
trace.id === traceId
|
||||
? !trace.bookmarked
|
||||
: trace.bookmarked,
|
||||
};
|
||||
})
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -114,16 +102,16 @@ export function StarTraceToggle({
|
||||
size={size}
|
||||
disabled={!hasAccess}
|
||||
isLoading={isLoading}
|
||||
onClick={(value) => {
|
||||
onClick={(newValue) => {
|
||||
capture("table:bookmark_button_click", {
|
||||
table: "traces",
|
||||
id: traceId,
|
||||
value: value,
|
||||
value: newValue,
|
||||
});
|
||||
return mutBookmarkTrace.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
bookmarked: value,
|
||||
bookmarked: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -10,19 +10,53 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { type VisibilityState } from "@tanstack/react-table";
|
||||
import { ChevronDown, Columns } from "lucide-react";
|
||||
import {
|
||||
type ColumnOrderState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns,
|
||||
Component,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { isString } from "@/src/utils/types";
|
||||
|
||||
interface DataTableColumnVisibilityFilterProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>;
|
||||
columnOrder?: ColumnOrderState;
|
||||
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
|
||||
}
|
||||
|
||||
const calculateColumnCounts = <TData, TValue>(
|
||||
@@ -57,18 +91,45 @@ function ColumnVisibilityDropdownItem<TData, TValue>({
|
||||
column,
|
||||
toggleColumn,
|
||||
columnVisibility,
|
||||
isOrderable = false,
|
||||
}: {
|
||||
column: LangfuseColumnDef<TData, TValue>;
|
||||
toggleColumn: (columnId: string) => void;
|
||||
columnVisibility: VisibilityState;
|
||||
isOrderable?: boolean;
|
||||
}) {
|
||||
if (column.enableHiding) {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={columnVisibility[column.accessorKey]}
|
||||
onCheckedChange={() => toggleColumn(column.accessorKey)}
|
||||
>
|
||||
<span className="capitalize">
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } =
|
||||
useSortable({
|
||||
id: column.accessorKey,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={columnVisibility[column.accessorKey] && column.enableHiding}
|
||||
onCheckedChange={() => {
|
||||
if (column.enableHiding) toggleColumn(column.accessorKey);
|
||||
}}
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isDragging ? "opacity-80" : "opacity-100",
|
||||
"group whitespace-nowrap",
|
||||
)}
|
||||
style={{
|
||||
transform: transform ? CSS.Translate.toString(transform) : "none",
|
||||
transition: "width transform 0.2s ease-in-out",
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
{!column.enableHiding && (
|
||||
<Check className="absolute left-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
<div className="mr-1">
|
||||
<span
|
||||
className="capitalize"
|
||||
title={
|
||||
!column.enableHiding ? "This column may not be hidden" : undefined
|
||||
}
|
||||
>
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
@@ -79,15 +140,78 @@ function ColumnVisibilityDropdownItem<TData, TValue>({
|
||||
href={column.headerTooltip.href}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
{isOrderable && (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
title="Drag and drop to reorder columns"
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
>
|
||||
<Menu className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupVisibilityDropdownHeader<TData, TValue>({
|
||||
column,
|
||||
}: {
|
||||
column: LangfuseColumnDef<TData, TValue>;
|
||||
}) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } =
|
||||
useSortable({
|
||||
id: column.accessorKey,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
hasCustomIcon
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isDragging ? "opacity-80" : "opacity-100",
|
||||
"group flex w-full items-center justify-between whitespace-nowrap",
|
||||
)}
|
||||
style={{
|
||||
transform: transform ? CSS.Translate.toString(transform) : "none",
|
||||
transition: "width transform 0.2s ease-in-out",
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Component className="mr-2 h-4 w-4 opacity-50" />
|
||||
<span>
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
title="Drag and drop to reorder columns"
|
||||
className="invisible group-hover:visible"
|
||||
>
|
||||
<Menu className="h-3 w-3" />
|
||||
</Button>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
columns,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnOrder,
|
||||
setColumnOrder,
|
||||
}: DataTableColumnVisibilityFilterProps<TData, TValue>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const capture = usePostHogClientCapture();
|
||||
@@ -110,62 +234,115 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[setColumnVisibility],
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {}),
|
||||
);
|
||||
|
||||
const { count, total } = calculateColumnCounts(columns, columnVisibility);
|
||||
const columnIdsOrder = columnOrder ?? columns.map((col) => col.accessorKey);
|
||||
const isColumnOrderingEnabled = !!setColumnOrder;
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active && over && active.id !== over.id) {
|
||||
const overColumn = columns.find((col) => col.accessorKey === over.id);
|
||||
if (overColumn?.isPinned) {
|
||||
return;
|
||||
}
|
||||
if (isString(active.id) && isString(over.id)) {
|
||||
setColumnOrder!((columnOrder) => {
|
||||
const oldIndex = columnOrder.indexOf(active.id as string);
|
||||
const newIndex = columnOrder.indexOf(over.id as string);
|
||||
return arrayMove(columnOrder, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen}>
|
||||
<DropdownMenuTrigger
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className="select-none"
|
||||
asChild
|
||||
>
|
||||
<Button variant="outline" title="Show/hide columns">
|
||||
<Columns className="mr-2 h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
className="max-h-96 overflow-y-auto"
|
||||
>
|
||||
{columns.map((column, index) => {
|
||||
if (!!column.columns && Boolean(column.columns.length)) {
|
||||
const isFollowingGroup = "columns" in (columns[index - 1] ?? {});
|
||||
return (
|
||||
<div key={index}>
|
||||
{!isFollowingGroup && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</DropdownMenuLabel>
|
||||
{column.columns.map((column) => (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={column.accessorKey}
|
||||
column={column}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={column.accessorKey}
|
||||
column={column}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={isColumnOrderingEnabled ? handleDragEnd : undefined}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DropdownMenu open={isOpen}>
|
||||
<DropdownMenuTrigger
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className="select-none"
|
||||
asChild
|
||||
>
|
||||
<Button variant="outline" title="Show/hide columns">
|
||||
<Columns className="mr-2 h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
className="max-h-96 overflow-y-auto"
|
||||
>
|
||||
<SortableContext
|
||||
items={columnIdsOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnIdsOrder.map((columnId, index) => {
|
||||
const column = columns.find(
|
||||
(col) => col.accessorKey === columnId,
|
||||
);
|
||||
if (column) {
|
||||
if (!!column.columns && Boolean(column.columns.length)) {
|
||||
return (
|
||||
<DropdownMenuSub key={index}>
|
||||
{isColumnOrderingEnabled ? (
|
||||
<GroupVisibilityDropdownHeader column={column} />
|
||||
) : (
|
||||
<DropdownMenuSubTrigger hasCustomIcon>
|
||||
<Component className="mr-2 h-4 w-4 opacity-50" />
|
||||
<span>
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
)}
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{column.columns.map((col) => (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={col.accessorKey}
|
||||
column={col}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
isOrderable={false} // grouped columns are not orderable, group may only be ordered as a whole
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
} else if (!column.isPinned)
|
||||
return (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={column.accessorKey}
|
||||
column={column}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
isOrderable={isColumnOrderingEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { DataTableColumnVisibilityFilter } from "@/src/components/table/data-tab
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { PopoverFilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import { type ColumnDefinition } from "@langfuse/shared";
|
||||
import { type VisibilityState } from "@tanstack/react-table";
|
||||
import {
|
||||
type ColumnOrderState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import {
|
||||
DataTableRowHeightSwitch,
|
||||
@@ -36,6 +39,8 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
| ((newState: FilterState) => void);
|
||||
columnVisibility?: VisibilityState;
|
||||
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
|
||||
columnOrder?: ColumnOrderState;
|
||||
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
|
||||
rowHeight?: RowHeight;
|
||||
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
|
||||
columnsWithCustomSelect?: string[];
|
||||
@@ -55,6 +60,8 @@ export function DataTableToolbar<TData, TValue>({
|
||||
setFilterState,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnOrder,
|
||||
setColumnOrder,
|
||||
rowHeight,
|
||||
setRowHeight,
|
||||
columnsWithCustomSelect,
|
||||
@@ -115,6 +122,8 @@ export function DataTableToolbar<TData, TValue>({
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
setColumnOrder={setColumnOrder}
|
||||
/>
|
||||
)}
|
||||
{!!rowHeight && !!setRowHeight && (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
type ColumnOrderState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
@@ -45,6 +46,8 @@ interface DataTableProps<TData, TValue> {
|
||||
setRowSelection?: OnChangeFn<RowSelectionState>;
|
||||
columnVisibility?: VisibilityState;
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
|
||||
columnOrder?: ColumnOrderState;
|
||||
onColumnOrderChange?: OnChangeFn<ColumnOrderState>;
|
||||
orderBy?: OrderByState;
|
||||
setOrderBy?: (s: OrderByState) => void;
|
||||
help?: { description: string; href: string };
|
||||
@@ -62,6 +65,18 @@ export interface AsyncTableData<T> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function insertArrayAfterKey(array: string[], toInsert: Map<string, string[]>) {
|
||||
return array.reduce<string[]>((acc, key) => {
|
||||
if (toInsert.has(key)) {
|
||||
acc.push(...toInsert.get(key)!);
|
||||
} else {
|
||||
acc.push(key);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function DataTable<TData extends object, TValue>({
|
||||
columns,
|
||||
data,
|
||||
@@ -70,6 +85,8 @@ export function DataTable<TData extends object, TValue>({
|
||||
setRowSelection,
|
||||
columnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
columnOrder,
|
||||
onColumnOrderChange,
|
||||
help,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
@@ -83,10 +100,23 @@ export function DataTable<TData extends object, TValue>({
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const flattedColumnsByGroup = useMemo(() => {
|
||||
const flatColumnsByGroup = new Map<string, string[]>();
|
||||
|
||||
columns.forEach((col) => {
|
||||
if (col.columns && Boolean(col.columns.length)) {
|
||||
const children = col.columns.map((child) => child.accessorKey);
|
||||
flatColumnsByGroup.set(col.accessorKey, children);
|
||||
}
|
||||
});
|
||||
return flatColumnsByGroup;
|
||||
}, [columns]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: data.data ?? [],
|
||||
columns,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnOrderChange: onColumnOrderChange,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: pagination !== undefined,
|
||||
@@ -111,6 +141,9 @@ export function DataTable<TData extends object, TValue>({
|
||||
columnFilters,
|
||||
pagination: pagination?.state,
|
||||
columnVisibility,
|
||||
columnOrder: columnOrder
|
||||
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
|
||||
: undefined,
|
||||
rowSelection,
|
||||
},
|
||||
manualFiltering: true,
|
||||
@@ -171,6 +204,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
const columnDef = header.column
|
||||
.columnDef as LangfuseColumnDef<ModelTableRow>;
|
||||
const sortingEnabled = columnDef.enableSorting;
|
||||
|
||||
return header.column.getIsVisible() ? (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -181,9 +215,8 @@ export function DataTable<TData extends object, TValue>({
|
||||
style={{
|
||||
width: `calc(var(--header-${header.id}-size) * 1px)`,
|
||||
}}
|
||||
title={sortingEnabled ? "Sort by this column" : ""}
|
||||
onClick={(event) => {
|
||||
event.preventDefault(); // Add this line
|
||||
event.preventDefault();
|
||||
|
||||
if (!setOrderBy || !columnDef.id || !sortingEnabled) {
|
||||
return;
|
||||
@@ -219,41 +252,40 @@ export function DataTable<TData extends object, TValue>({
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
<div className="flex select-none items-center">
|
||||
<span className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
{columnDef.headerTooltip && (
|
||||
<DocPopup
|
||||
description={
|
||||
columnDef.headerTooltip.description
|
||||
}
|
||||
href={columnDef.headerTooltip.href}
|
||||
/>
|
||||
<div className="flex select-none items-center">
|
||||
<span className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{orderBy?.column === columnDef.id
|
||||
? renderOrderingIndicator(orderBy)
|
||||
: null}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
|
||||
header.column.getIsResizing() &&
|
||||
"bg-primary-accent opacity-100",
|
||||
)}
|
||||
</span>
|
||||
{columnDef.headerTooltip && (
|
||||
<DocPopup
|
||||
description={
|
||||
columnDef.headerTooltip.description
|
||||
}
|
||||
href={columnDef.headerTooltip.href}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{orderBy?.column === columnDef.id
|
||||
? renderOrderingIndicator(orderBy)
|
||||
: null}
|
||||
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
|
||||
header.column.getIsResizing() &&
|
||||
"bg-primary-accent opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
) : null;
|
||||
@@ -302,7 +334,12 @@ export function DataTable<TData extends object, TValue>({
|
||||
function renderOrderingIndicator(orderBy?: OrderByState) {
|
||||
if (!orderBy) return null;
|
||||
if (orderBy.order === "ASC") return <span className="ml-1">▲</span>;
|
||||
else return <span className="ml-1">▼</span>;
|
||||
else
|
||||
return (
|
||||
<span className="ml-1" title="Sort by this column">
|
||||
▼
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyComponentProps<TData> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user