Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
898cca56f9 | ||
|
|
83e052e769 | ||
|
|
b5f19771fa | ||
|
|
644390183b | ||
|
|
4d8e9003fe | ||
|
|
651c46a270 | ||
|
|
6b127bced0 | ||
|
|
e3a837b561 | ||
|
|
11c04f02c8 | ||
|
|
536aa138bc | ||
|
|
61b596c46a | ||
|
|
ddf43c0836 | ||
|
|
d5ab03892a | ||
|
|
918b6643d2 | ||
|
|
937b4c0f0f | ||
|
|
9b7f046e71 | ||
|
|
fe63704508 | ||
|
|
c39279f677 | ||
|
|
4206f5025d | ||
|
|
0260a4e861 | ||
|
|
244adf0dc3 | ||
|
|
dc6b98d009 | ||
|
|
b20fc5a20b | ||
|
|
f59ad5fd60 | ||
|
|
06ef41e372 | ||
|
|
e215a2db99 | ||
|
|
5e247e88c9 | ||
|
|
8e1e27c868 | ||
|
|
4648a5be8b | ||
|
|
4677cb10a9 | ||
|
|
7ed03a0946 | ||
|
|
bbe40a0ba8 | ||
|
|
2cf9a75fa5 | ||
|
|
38acccbfac | ||
|
|
8327863176 | ||
|
|
6045324d2b | ||
|
|
87923e5d6c | ||
|
|
f584de7577 | ||
|
|
d01c403f62 | ||
|
|
a001e6072c | ||
|
|
d9a3f977bc | ||
|
|
8fae4f3051 | ||
|
|
c36b1ff51d | ||
|
|
d3cf604f4f | ||
|
|
cc886917b6 | ||
|
|
b924bf9e09 | ||
|
|
895825dd1c | ||
|
|
6ceccb649f | ||
|
|
911021ac5d | ||
|
|
f39778ef9f | ||
|
|
4d00e39c6b | ||
|
|
7af49fd649 | ||
|
|
662f1917b6 | ||
|
|
d8f047f2a1 | ||
|
|
69815a51b9 | ||
|
|
2bb419213a | ||
|
|
a330035c40 | ||
|
|
dd4434fe25 | ||
|
|
f898f57bff | ||
|
|
eda52c7b77 | ||
|
|
e5c7686bc7 | ||
|
|
251be1a240 | ||
|
|
66c810e4a7 | ||
|
|
c3c8a741e4 | ||
|
|
81c898b4ed | ||
|
|
834d0bcc7a | ||
|
|
40953d7732 | ||
|
|
d471be8783 | ||
|
|
0ca38d8070 | ||
|
|
12458e3d71 | ||
|
|
51575b5491 | ||
|
|
a9c11f3323 | ||
|
|
066140c903 | ||
|
|
0b7de157ad | ||
|
|
7dcc718c77 | ||
|
|
e098618614 | ||
|
|
3e8ba54aa0 | ||
|
|
ea36cd58a4 | ||
|
|
c7b726a73a | ||
|
|
112e7a2dcf | ||
|
|
c1d98aba6a | ||
|
|
75fd1cbd3f | ||
|
|
75ec734a45 | ||
|
|
98e951423c | ||
|
|
8e1dc3ee99 | ||
|
|
7668995ec9 | ||
|
|
06233f878c | ||
|
|
dae3d4e4e2 | ||
|
|
04dca2ad6b | ||
|
|
48c61ad77f | ||
|
|
34140302a6 | ||
|
|
93cc36ecdc | ||
|
|
ace30782a0 | ||
|
|
3c94a0cda9 | ||
|
|
b0fea44556 | ||
|
|
ed91330c37 | ||
|
|
b8ed9d681b | ||
|
|
ea2e1b4873 | ||
|
|
3ddef14e75 | ||
|
|
4658942701 | ||
|
|
51be8f8fb3 | ||
|
|
6ede3b7690 | ||
|
|
dee36bbe29 | ||
|
|
f71cf2f894 | ||
|
|
1a707fe147 | ||
|
|
8da1640777 | ||
|
|
1e56499f00 | ||
|
|
2524fb9648 | ||
|
|
91cd86a286 | ||
|
|
e6b9d23615 | ||
|
|
8b76feeffa | ||
|
|
88a53dc72d | ||
|
|
649a9af91c | ||
|
|
876917d112 | ||
|
|
bb6bc79bdc | ||
|
|
7aff2b2e61 | ||
|
|
8f742f660a |
@@ -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"
|
||||
|
||||
+27
-4
@@ -28,6 +28,9 @@ 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
|
||||
@@ -39,6 +42,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Logging, optional
|
||||
# LANGFUSE_LOG_LEVEL=info
|
||||
# LANGFUSE_LOG_FORMAT=text
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
@@ -97,6 +104,17 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
# LANGFUSE_INIT_PROJECT_ID=project-id
|
||||
# LANGFUSE_INIT_PROJECT_NAME=project-name
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
|
||||
# LANGFUSE_INIT_USER_EMAIL=user@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
@@ -109,6 +127,8 @@ 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
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
@@ -139,13 +159,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Sentry
|
||||
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# Used to determine the Sentry sample rate
|
||||
# LANGFUSE_TRACING_SAMPLE_RATE=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
@@ -167,6 +186,7 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
@@ -174,6 +194,9 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
@@ -197,8 +220,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
+20
-7
@@ -8,6 +8,7 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/" # Location of package manifests
|
||||
rebase-strategy: "disabled" # use dependabot-rebase-stale
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: "increase"
|
||||
@@ -19,17 +20,29 @@ updates:
|
||||
- dependency-name: "@types/node"
|
||||
- dependency-name: "@trpc/*"
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
prisma:
|
||||
patterns:
|
||||
- "prisma"
|
||||
- "@prisma/*"
|
||||
- "@prisma/client"
|
||||
next:
|
||||
patterns:
|
||||
- "eslint-config-next"
|
||||
- "next"
|
||||
patches:
|
||||
update-types:
|
||||
- "patch"
|
||||
lodash:
|
||||
patterns:
|
||||
- "lodash"
|
||||
- "@types/lodash"
|
||||
express:
|
||||
patterns:
|
||||
- "express"
|
||||
- "@types/express"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
- "@opentelemetry/*"
|
||||
- "@appsignal/opentelemetry-instrumentation-bullmq"
|
||||
- "@prisma/instrumentation"
|
||||
- "@sentry/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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 }} \
|
||||
.
|
||||
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
|
||||
@@ -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 }}
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and run both images from compose
|
||||
run: |
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
@@ -94,7 +97,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: |
|
||||
@@ -107,13 +110,26 @@ jobs:
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
|
||||
- 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&)
|
||||
env:
|
||||
LANGFUSE_INIT_ORG_ID: "seed-org-id"
|
||||
LANGFUSE_INIT_ORG_NAME: "Seed Org"
|
||||
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
|
||||
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
|
||||
LANGFUSE_INIT_USER_NAME: "Demo User"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=web run test
|
||||
@@ -136,6 +152,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -165,18 +182,26 @@ jobs:
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
if docker compose ps | grep "(unhealthy)"; then
|
||||
echo "One or more services are unhealthy"
|
||||
exit 1
|
||||
else
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run --filter=shared ch:up
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run --filter=shared ch:up
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter=worker... run build
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=worker run test
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -308,6 +333,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
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,11 +1,11 @@
|
||||
name: Snyk Container
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,24 +11,17 @@ groups:
|
||||
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.41.1"
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.74.0",
|
||||
"version": "2.80.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -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.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -18,5 +18,6 @@ if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"import": "./dist/src/server/index.js",
|
||||
"require": "./dist/src/server/index.js"
|
||||
},
|
||||
"./src/server/auth/apiKeys": {
|
||||
"import": "./dist/src/server/auth/apiKeys.js",
|
||||
"require": "./dist/src/server/auth/apiKeys.js"
|
||||
},
|
||||
"./encryption": {
|
||||
"import": "./dist/src/encryption/index.js",
|
||||
"require": "./dist/src/encryption/index.js"
|
||||
@@ -51,7 +55,7 @@
|
||||
"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",
|
||||
@@ -63,25 +67,27 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"bullmq": "^5.12.10",
|
||||
"dd-trace": "^5.19.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"dd-trace": "^5.19.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.14.2",
|
||||
"winston-transport": "^4.7.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
"zod-to-json-schema": "^3.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
@@ -103,7 +109,7 @@
|
||||
"vitest": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.79",
|
||||
"react": "^18.0.0"
|
||||
"@types/react": "~18.2.79",
|
||||
"react": "~18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -34,21 +34,22 @@ generator kysely {
|
||||
|
||||
// Necessary for Next auth
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? // @db.Text
|
||||
access_token String? // @db.Text
|
||||
expires_at Int?
|
||||
expires_in Int?
|
||||
ext_expires_in Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? // @db.Text
|
||||
session_state String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? // @db.Text
|
||||
access_token String? // @db.Text
|
||||
expires_at Int?
|
||||
expires_in Int?
|
||||
ext_expires_in Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? // @db.Text
|
||||
session_state String?
|
||||
refresh_token_expires_in Int?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@index([userId])
|
||||
|
||||
@@ -12,7 +12,7 @@ import { parseArgs } from "node:util";
|
||||
import { chunk } from "lodash";
|
||||
import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../src/server";
|
||||
import { encrypt } from "../src/encryption";
|
||||
import { redis } from "../src/server/redis/redis";
|
||||
|
||||
@@ -265,11 +265,11 @@ async function main() {
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
configIdsAndNames,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
|
||||
logger.info(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`,
|
||||
);
|
||||
|
||||
await uploadObjects(
|
||||
@@ -278,7 +278,7 @@ async function main() {
|
||||
scores,
|
||||
sessions,
|
||||
events,
|
||||
comments
|
||||
comments,
|
||||
);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
@@ -295,8 +295,8 @@ async function main() {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
|
||||
logger.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ async function main() {
|
||||
|
||||
for (const datasetItemId of datasetItemIds) {
|
||||
const relevantObservations = observations.filter(
|
||||
(o) => o.projectId === project2.id
|
||||
(o) => o.projectId === project2.id,
|
||||
);
|
||||
const observation =
|
||||
relevantObservations[
|
||||
@@ -457,13 +457,13 @@ main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
logger.error(e);
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ async function uploadObjects(
|
||||
scores: Prisma.ScoreCreateManyInput[],
|
||||
sessions: Prisma.TraceSessionCreateManyInput[],
|
||||
events: Prisma.ObservationCreateManyInput[],
|
||||
comments: Prisma.CommentCreateManyInput[]
|
||||
comments: Prisma.CommentCreateManyInput[],
|
||||
) {
|
||||
let promises: Prisma.PrismaPromise<unknown>[] = [];
|
||||
|
||||
@@ -487,14 +487,14 @@ async function uploadObjects(
|
||||
},
|
||||
create: chunk[0]!,
|
||||
update: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -505,13 +505,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.trace.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -521,14 +521,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -538,14 +538,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -555,13 +555,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.score.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -571,13 +571,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.comment.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -598,7 +598,7 @@ function createObjects(
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
}[]
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const traces: Prisma.TraceCreateManyInput[] = [];
|
||||
const observations: Prisma.ObservationCreateManyInput[] = [];
|
||||
@@ -612,7 +612,7 @@ function createObjects(
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
// random date within last 90 days, with a linear bias towards more recent dates
|
||||
const traceTs = new Date(
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000)
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
const envTag = envTags[Math.floor(Math.random() * envTags.length)];
|
||||
@@ -753,11 +753,11 @@ function createObjects(
|
||||
for (let j = 0; j < Math.floor(Math.random() * 10) + 1; j++) {
|
||||
// add between 1 and 30 ms to trace timestamp
|
||||
const spanTsStart = new Date(
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30)
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30),
|
||||
);
|
||||
// random duration of upto 5000ms
|
||||
const spanTsEnd = new Date(
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000)
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000),
|
||||
);
|
||||
|
||||
const span = {
|
||||
@@ -792,22 +792,22 @@ function createObjects(
|
||||
const generationTsStart = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
const generationTsEnd = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() *
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime())
|
||||
)
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
// somewhere in the middle
|
||||
const generationTsCompletionStart = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3
|
||||
)
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3,
|
||||
),
|
||||
);
|
||||
|
||||
const promptTokens = Math.floor(Math.random() * 1000) + 300;
|
||||
@@ -828,7 +828,7 @@ function createObjects(
|
||||
const promptId =
|
||||
promptIds.get(projectId)![
|
||||
Math.floor(
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2)
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2),
|
||||
)
|
||||
];
|
||||
|
||||
@@ -910,8 +910,8 @@ function createObjects(
|
||||
const eventTs = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
|
||||
events.push({
|
||||
@@ -933,7 +933,7 @@ function createObjects(
|
||||
}
|
||||
// find unique sessions by id and projectid
|
||||
const uniqueSessions: Prisma.TraceSessionCreateManyInput[] = Array.from(
|
||||
new Set(sessions.map((session) => JSON.stringify(session)))
|
||||
new Set(sessions.map((session) => JSON.stringify(session))),
|
||||
).map((session) => JSON.parse(session) as Prisma.TraceSessionCreateManyInput);
|
||||
|
||||
return {
|
||||
@@ -954,7 +954,7 @@ async function generatePromptsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const promptIdsForProject = await generatePrompts(project);
|
||||
promptIds.set(project.id, promptIdsForProject);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return promptIds;
|
||||
}
|
||||
@@ -1140,7 +1140,7 @@ async function generateConfigsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const configNameAndId = await generateConfigs(project);
|
||||
projectIdsToConfigs.set(project.id, configNameAndId);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return projectIdsToConfigs;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file exports the prisma db connection, the Prisma Object, and the Typescript types.
|
||||
// This is not imported in the index.ts file of this package, as we must not import this into FE code.
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { env } from "process";
|
||||
import kyselyExtension from "prisma-extension-kysely";
|
||||
import {
|
||||
@@ -11,17 +11,38 @@ import {
|
||||
PostgresQueryCompiler,
|
||||
} from "kysely";
|
||||
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
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error", "warn"],
|
||||
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}`);
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
@@ -38,7 +59,7 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
declare global {
|
||||
|
||||
@@ -16,11 +16,12 @@ const EnvSchema = z.object({
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
64,
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
@@ -37,6 +38,11 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
SALT: z.string().optional(), // used by components imported by web package
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.optional(),
|
||||
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum BatchExportFileFormat {
|
||||
|
||||
export enum BatchExportTableName {
|
||||
Sessions = "sessions",
|
||||
Traces = "traces",
|
||||
}
|
||||
|
||||
export const exportOptions: Record<
|
||||
|
||||
+3
@@ -12,3 +12,6 @@ export const plans = Object.keys(planLabels) as Plan[];
|
||||
|
||||
// This function is kept here to ensure consistency when updating plan names in the future.
|
||||
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
|
||||
|
||||
export const isPlan = (value: string): value is Plan =>
|
||||
plans.includes(value as Plan);
|
||||
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -161,7 +161,7 @@ export const ScorePropsAgainstConfig = z.union([
|
||||
*/
|
||||
export const filterAndValidateDbScoreList = (
|
||||
scores: Score[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): APIScore[] =>
|
||||
scores.reduce((acc, ts) => {
|
||||
const result = APIScoreSchema.safeParse(ts);
|
||||
@@ -198,14 +198,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -215,14 +215,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -256,7 +256,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
|
||||
trace: z.object({
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
export const GetScoresResponse = z.object({
|
||||
data: z.array(LegacyGetScoreResponseDataV1),
|
||||
@@ -265,7 +265,7 @@ export const GetScoresResponse = z.object({
|
||||
|
||||
export const legacyFilterAndValidateV1GetScoreList = (
|
||||
scores: unknown[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
|
||||
scores.reduce(
|
||||
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
|
||||
@@ -278,7 +278,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
|
||||
);
|
||||
|
||||
// GET /scores/{scoreId}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
export * from "./constants";
|
||||
export * from "./queries";
|
||||
export * from "./interfaces/filters";
|
||||
export * from "./interfaces/orderBy";
|
||||
export * from "./interfaces/cloudConfigSchema";
|
||||
export * from "./interfaces/parseDbOrg";
|
||||
export * from "./tableDefinitions";
|
||||
export * from "./types";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth/auth";
|
||||
export * from "./server/auth/apiKeys";
|
||||
export * from "./observationsTable";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export * from "./utils/typeChecks";
|
||||
export * from "./features/entitlements/plans";
|
||||
export * from "./interfaces/rate-limits";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { CloudConfigRateLimit } from "./rate-limits";
|
||||
|
||||
export const CloudConfigSchema = z.object({
|
||||
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
|
||||
@@ -13,5 +14,9 @@ export const CloudConfigSchema = z.object({
|
||||
activeProductId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// custom rate limits for an organization
|
||||
rateLimitOverrides: CloudConfigRateLimit.optional(),
|
||||
});
|
||||
|
||||
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import z from "zod";
|
||||
import { ApiAccessScope } from "../server";
|
||||
|
||||
export const RateLimitResource = z.enum([
|
||||
"ingestion",
|
||||
"public-api",
|
||||
"public-api-metrics",
|
||||
"prompts",
|
||||
]);
|
||||
|
||||
// result of the rate limit check.
|
||||
export type RateLimitResult = {
|
||||
resource: z.infer<typeof RateLimitResource>;
|
||||
points: number;
|
||||
scope: ApiAccessScope;
|
||||
|
||||
// from rate-limiter-flexible
|
||||
remainingPoints: number;
|
||||
msBeforeNext: number;
|
||||
consumedPoints: number;
|
||||
isFirstInDuration: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitConfig = z.object({
|
||||
points: z.number().nullish(),
|
||||
durationInSec: z.number().nullish(),
|
||||
resource: RateLimitResource,
|
||||
});
|
||||
|
||||
export const CloudConfigRateLimit = z.array(RateLimitConfig);
|
||||
@@ -18,6 +18,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 'o."name"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{ name: "Trace ID", id: "traceId", type: "string", internal: 't."id"' },
|
||||
{
|
||||
@@ -26,8 +27,15 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "User ID",
|
||||
id: "userId",
|
||||
type: "string",
|
||||
internal: 't."user_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "Start Time",
|
||||
id: "startTime",
|
||||
@@ -44,7 +52,9 @@ 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,
|
||||
},
|
||||
{
|
||||
name: "Latency (s)",
|
||||
@@ -57,24 +67,28 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "timePerOutputToken",
|
||||
type: "number",
|
||||
internal: '"latency" / o."completion_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Cost ($)",
|
||||
id: "inputCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_input_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Cost ($)",
|
||||
id: "outputCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_output_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Cost ($)",
|
||||
id: "totalCost",
|
||||
type: "number",
|
||||
internal: 'o."calculated_total_cost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Level",
|
||||
@@ -88,6 +102,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "statusMessage",
|
||||
type: "string",
|
||||
internal: 'o."status_message"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Model",
|
||||
@@ -95,24 +110,28 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 'o."model"',
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Input Tokens",
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'o."prompt_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'o."completion_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'o."total_tokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
@@ -137,6 +156,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 'o."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Prompt Name",
|
||||
@@ -144,12 +164,21 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: "p.name",
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Prompt Version",
|
||||
id: "promptVersion",
|
||||
type: "number",
|
||||
internal: "p.version",
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Trace Tags",
|
||||
id: "tags",
|
||||
type: "arrayOptions",
|
||||
internal: "t.tags",
|
||||
options: [], // to be added at runtime
|
||||
},
|
||||
];
|
||||
|
||||
@@ -161,6 +190,7 @@ export type ObservationOptions = {
|
||||
traceName: Array<OptionsDefinition>;
|
||||
scores_avg: Array<string>;
|
||||
promptName: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
@@ -182,6 +212,9 @@ export function observationsTableColsWithOptions(
|
||||
if (col.id === "promptName") {
|
||||
return { ...col, options: options?.promptName ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { getSessionTableSQL } from "./getSessionTableSQL";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { env } from "../../env";
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
async function generateKeySet() {
|
||||
return {
|
||||
pk: `pk-lf-${randomUUID()}`,
|
||||
sk: `sk-lf-${randomUUID()}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function createAndAddApiKeysToDb(p: {
|
||||
prisma: PrismaClient;
|
||||
projectId: string;
|
||||
note?: string;
|
||||
predefinedKeys?: {
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
};
|
||||
}) {
|
||||
const salt = env.SALT;
|
||||
if (!salt) {
|
||||
throw new Error("SALT is not set");
|
||||
}
|
||||
|
||||
const { pk, sk } = p.predefinedKeys
|
||||
? { pk: p.predefinedKeys.publicKey, sk: p.predefinedKeys.secretKey }
|
||||
: await generateKeySet();
|
||||
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
const hashFromProvidedKey = createShaHash(sk, salt);
|
||||
|
||||
const apiKey = await p.prisma.apiKey.create({
|
||||
data: {
|
||||
projectId: p.projectId,
|
||||
publicKey: pk,
|
||||
hashedSecretKey: hashedSk,
|
||||
displaySecretKey: displaySk,
|
||||
fastHashedSecretKey: hashFromProvidedKey,
|
||||
note: p.note,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
createdAt: apiKey.createdAt,
|
||||
note: apiKey.note,
|
||||
publicKey: apiKey.publicKey,
|
||||
secretKey: sk,
|
||||
displaySecretKey: displaySk,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
export function generateSecretKey() {
|
||||
return `sk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function generatePublicKey() {
|
||||
return `pk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
export async function generateKeySet() {
|
||||
const pk = generatePublicKey();
|
||||
const sk = generateSecretKey();
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
return {
|
||||
pk,
|
||||
sk,
|
||||
hashedSk,
|
||||
displaySk,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,30 @@
|
||||
import z from "zod";
|
||||
import { Plan, plans } from "../../features/entitlements/plans";
|
||||
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
|
||||
|
||||
export const OrgEnrichedApiKey = z.object({
|
||||
id: z.string(),
|
||||
note: z.string().nullable(),
|
||||
publicKey: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
createdAt: z.string().datetime().nullable(),
|
||||
lastUsedAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
projectId: z.string(),
|
||||
fastHashedSecretKey: z.string(),
|
||||
hashedSecretKey: z.string(),
|
||||
orgId: z.string(),
|
||||
plan: z.enum(plans as unknown as [string, ...string[]]),
|
||||
rateLimitOverrides: CloudConfigRateLimit.nullish(),
|
||||
});
|
||||
|
||||
export const API_KEY_NON_EXISTENT = "api-key-non-existent";
|
||||
|
||||
export const CachedApiKey = z.union([
|
||||
OrgEnrichedApiKey,
|
||||
z.literal(API_KEY_NON_EXISTENT),
|
||||
]);
|
||||
|
||||
export type AuthHeaderVerificationResult =
|
||||
| AuthHeaderValidVerificationResult
|
||||
| {
|
||||
@@ -13,4 +40,7 @@ export type AuthHeaderValidVerificationResult = {
|
||||
export type ApiAccessScope = {
|
||||
projectId: string;
|
||||
accessLevel: "all" | "scores";
|
||||
orgId: string;
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
};
|
||||
|
||||
+22
-4
@@ -1,7 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators } from "./interfaces/filters";
|
||||
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
|
||||
import { FilterState } from "../types";
|
||||
import { filterOperators, timeFilter } from "../interfaces/filters";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./index";
|
||||
|
||||
const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
@@ -50,7 +52,7 @@ export function tableColumnsToSqlFilter(
|
||||
c.name === filter.column || c.id === filter.column
|
||||
);
|
||||
if (!col) {
|
||||
console.error("Invalid filter column", filter.column);
|
||||
logger.error("Invalid filter column", filter.column);
|
||||
throw new Error("Invalid filter column: " + filter.column);
|
||||
}
|
||||
const colPrisma = Prisma.raw(col.internal);
|
||||
@@ -179,3 +181,19 @@ export const datetimeFilterToPrismaSql = (
|
||||
operator
|
||||
)} ${value}::timestamp with time zone at time zone 'UTC'`;
|
||||
};
|
||||
|
||||
export const datetimeFilterToPrisma = (
|
||||
timestampFilter: z.infer<typeof timeFilter>
|
||||
) => {
|
||||
const prismaTimestampFilter =
|
||||
timestampFilter.operator === ">="
|
||||
? { gte: timestampFilter.value }
|
||||
: timestampFilter.operator === ">"
|
||||
? { gt: timestampFilter.value }
|
||||
: timestampFilter.operator === "<="
|
||||
? { lte: timestampFilter.value }
|
||||
: timestampFilter.operator === "<"
|
||||
? { lt: timestampFilter.value }
|
||||
: {};
|
||||
return prismaTimestampFilter;
|
||||
};
|
||||
@@ -3,7 +3,8 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth/auth";
|
||||
export * from "./auth/apiKeys";
|
||||
export * from "./auth/customSsoProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
@@ -24,4 +25,8 @@ export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./instrumentation";
|
||||
export * from "./logger";
|
||||
export * from "./queries";
|
||||
|
||||
@@ -12,18 +12,20 @@ import {
|
||||
sdkLogEvent,
|
||||
} from "../types";
|
||||
import { validateAndInflateScore } from "../validateAndInflateScore";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
|
||||
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
|
||||
import { mergeJson } from "../../../utils/json";
|
||||
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: ApiAccessScope): void;
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
@@ -46,8 +48,8 @@ export class ObservationProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: ApiAccessScope,
|
||||
existingObservation: Observation | null
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
existingObservation: Omit<Observation, "input" | "output"> | null
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
@@ -119,7 +121,8 @@ 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,
|
||||
@@ -187,8 +190,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
: undefined;
|
||||
|
||||
// Only null if promptName and promptVersion are set but prompt is not found
|
||||
if (prompt === null)
|
||||
console.warn("Prompt not found for observation", this.event.body);
|
||||
if (prompt === null) {
|
||||
logger.warn("Prompt not found for observation", this.event.body);
|
||||
}
|
||||
|
||||
const observationId = this.event.body.id ?? v4();
|
||||
|
||||
@@ -309,7 +313,8 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
calculateTokenCounts(
|
||||
async calculateTokenCounts(
|
||||
projectId: string,
|
||||
body:
|
||||
| z.infer<typeof legacyObservationCreateEvent>["body"]
|
||||
| z.infer<typeof generationCreateEvent>["body"],
|
||||
@@ -318,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(
|
||||
@@ -398,17 +433,53 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for observation creation");
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
|
||||
this.auth(apiScope);
|
||||
|
||||
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;
|
||||
|
||||
@@ -441,13 +512,13 @@ export class TraceProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for trace creation");
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -455,11 +526,8 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
console.log(
|
||||
"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({
|
||||
@@ -553,7 +621,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
@@ -561,7 +629,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -613,13 +681,13 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
process() {
|
||||
try {
|
||||
console.log("SDK Log", this.event);
|
||||
logger.info("SDK Log", this.event);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IngestionUtils } from "../IngestionUtils";
|
||||
import { IngestionEventType } from "../types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { env } from "../../../env";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export async function enqueueIngestionEvents(
|
||||
projectId: string,
|
||||
events: IngestionEventType[]
|
||||
events: IngestionEventType[],
|
||||
) {
|
||||
const ingestionFlushQueue = getIngestionFlushQueue();
|
||||
|
||||
@@ -31,8 +32,8 @@ export async function enqueueIngestionEvents(
|
||||
event,
|
||||
redis,
|
||||
ingestionFlushQueue,
|
||||
batchTimestamp
|
||||
)
|
||||
batchTimestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,11 +45,11 @@ async function enqueueSingleIngestionEvent(
|
||||
event: IngestionEventType,
|
||||
redis: Redis,
|
||||
ingestionFlushQueue: IngestionFlushQueue,
|
||||
batchTimestamp: string
|
||||
batchTimestamp: string,
|
||||
): Promise<void> {
|
||||
if (!("id" in event.body && event.body.id)) {
|
||||
console.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`
|
||||
logger.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`,
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
convertTraceUpsertEventsToRedisEvents,
|
||||
getTraceUpsertQueue,
|
||||
} from "../../redis/trace-upsert";
|
||||
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export type BatchResult = {
|
||||
result: unknown;
|
||||
@@ -31,12 +32,27 @@ type TokenCountInput = {
|
||||
text: unknown;
|
||||
};
|
||||
|
||||
export type LegacyIngestionAccessScope = Omit<
|
||||
ApiAccessScope,
|
||||
"orgId" | "plan" | "rateLimitOverrides"
|
||||
>;
|
||||
|
||||
type LegacyIngestionAuthHeaderVerificationResult =
|
||||
| {
|
||||
validKey: true;
|
||||
scope: LegacyIngestionAccessScope;
|
||||
}
|
||||
| {
|
||||
validKey: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const handleBatch = async (
|
||||
events: z.infer<typeof ingestionApiSchema>["batch"],
|
||||
authCheck: AuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
authCheck: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
logger.debug(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
@@ -54,7 +70,7 @@ export const handleBatch = async (
|
||||
return await handleSingleEvent(
|
||||
singleEvent,
|
||||
authCheck.scope,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
});
|
||||
results.push({
|
||||
@@ -64,7 +80,7 @@ export const handleBatch = async (
|
||||
}); // Push each result into the array
|
||||
} catch (error) {
|
||||
// Handle or log the error if `handleSingleEvent` fails
|
||||
console.error("Error handling event:", error);
|
||||
logger.error("Error handling event:", error);
|
||||
// Decide how to handle the error: rethrow, continue, or push an error object to results
|
||||
// For example, push an error object:
|
||||
errors.push({
|
||||
@@ -78,9 +94,9 @@ export const handleBatch = async (
|
||||
if (env.CLICKHOUSE_URL) {
|
||||
try {
|
||||
await enqueueIngestionEvents(authCheck.scope.projectId, events);
|
||||
console.log(`Added ${events.length} ingestion events to queue`);
|
||||
logger.info(`Added ${events.length} ingestion events to queue`);
|
||||
} catch (err) {
|
||||
console.error("Error adding ingestion events to queue", err);
|
||||
logger.error("Error adding ingestion events to queue", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +108,10 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
|
||||
retry: (e: Error, attemptNumber: number) => {
|
||||
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
|
||||
console.log("not retrying auth error");
|
||||
logger.info("not retrying auth error");
|
||||
return false;
|
||||
}
|
||||
console.log(`retrying processing events ${attemptNumber}`);
|
||||
logger.info(`retrying processing events ${attemptNumber}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -103,11 +119,11 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
|
||||
const handleSingleEvent = async (
|
||||
event: z.infer<typeof ingestionEvent>,
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
}) => number | undefined,
|
||||
) => {
|
||||
const { body } = event;
|
||||
let restEvent = body;
|
||||
@@ -122,8 +138,8 @@ const handleSingleEvent = async (
|
||||
restEvent = rest;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
|
||||
logger.info(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`,
|
||||
);
|
||||
|
||||
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
|
||||
@@ -144,7 +160,7 @@ const handleSingleEvent = async (
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
processor = new ObservationProcessor(
|
||||
cleanedEvent,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
break;
|
||||
case eventTypes.SCORE_CREATE: {
|
||||
@@ -186,7 +202,7 @@ export function cleanEvent(obj: unknown): unknown {
|
||||
}
|
||||
|
||||
export const isNotNullOrUndefined = <T>(
|
||||
val?: T | null
|
||||
val?: T | null,
|
||||
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
|
||||
|
||||
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
@@ -194,7 +210,7 @@ export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
|
||||
export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
batchResults: BatchResult[],
|
||||
projectId: string
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
const traceEvents: TraceUpsertEventType[] = batchResults
|
||||
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
|
||||
@@ -204,17 +220,17 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
"id" in result.result
|
||||
? // ingestion API only gets traces for one projectId
|
||||
{ traceId: result.result.id as string, projectId }
|
||||
: null
|
||||
: null,
|
||||
)
|
||||
.filter(isNotNullOrUndefined);
|
||||
|
||||
try {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
|
||||
const queue = getTraceUpsertQueue();
|
||||
if (!queue) {
|
||||
console.error("TraceUpsertQueue not initialized");
|
||||
logger.error("TraceUpsertQueue not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,7 +240,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
env.LANGFUSE_WORKER_PASSWORD &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
const body: EventBodyType = {
|
||||
name: EventName.TraceUpsert,
|
||||
payload: traceEvents,
|
||||
@@ -238,7 +254,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
Authorization:
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD,
|
||||
).toString("base64"),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
@@ -247,6 +263,6 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending events to worker", error);
|
||||
logger.error("Error sending events to worker", 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
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidateAndInflateScoreParams = {
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams
|
||||
params: ValidateAndInflateScoreParams,
|
||||
): Promise<Score> {
|
||||
const { body, projectId } = params;
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project"
|
||||
"The configId you provided does not match a valid config in this project",
|
||||
);
|
||||
|
||||
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
@@ -47,7 +47,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!validation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function inferDataType(value: string | number): ScoreDataType {
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
@@ -71,7 +71,7 @@ function mapStringValueToNumericValue(
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
|
||||
): Score {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
@@ -105,25 +105,25 @@ function inflateScoreBody(
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
config: ValidatedScoreConfig
|
||||
config: ValidatedScoreConfig,
|
||||
): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
throw new InvalidRequestError(
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first."
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.name !== body.name) {
|
||||
throw new InvalidRequestError(
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function validateConfigAgainstBody(
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function validateConfigAgainstBody(
|
||||
.join(", ");
|
||||
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,61 +3,105 @@ 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 CallbackFn<T> = () => T | Promise<T>;
|
||||
type AsyncCallbackFn<T> = (span: opentelemetry.Span) => Promise<T>;
|
||||
|
||||
export function instrument<T>(
|
||||
export async function instrumentAsync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: CallbackFn<T>
|
||||
): T extends Promise<any> ? Promise<T> : T {
|
||||
callback: AsyncCallbackFn<T>,
|
||||
): Promise<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: ctx.rootSpan,
|
||||
root: !Boolean(ctx.traceContext) && ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
(span) => {
|
||||
const handleResult = (result: T) => {
|
||||
activeContext,
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await callback(span);
|
||||
span.end();
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleError = (ex: unknown) => {
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then(handleResult)
|
||||
.catch(handleError) as T extends Promise<any> ? Promise<T> : T;
|
||||
} else {
|
||||
return handleResult(result) as T extends Promise<any>
|
||||
? Promise<T>
|
||||
: T;
|
||||
}
|
||||
} catch (ex) {
|
||||
return handleError(ex) as T extends Promise<any> ? Promise<T> : T;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
|
||||
|
||||
export const addTraceContext = <T extends Record<string, any>>(
|
||||
input: T,
|
||||
): T & { _tracecontext?: TCarrier } => {
|
||||
const context = {};
|
||||
opentelemetry.propagation.inject(opentelemetry.context.active(), context);
|
||||
|
||||
return {
|
||||
...input,
|
||||
_tracecontext: context,
|
||||
};
|
||||
};
|
||||
|
||||
export const traceException = (
|
||||
ex: unknown,
|
||||
span?: opentelemetry.Span,
|
||||
code?: string
|
||||
code?: string,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
@@ -67,9 +111,24 @@ export const traceException = (
|
||||
|
||||
const exception = {
|
||||
code: code,
|
||||
message: ex instanceof Error ? ex.message : String(ex),
|
||||
name: ex instanceof Error ? ex.name : "Error",
|
||||
stack: ex instanceof Error ? ex.stack : undefined,
|
||||
message:
|
||||
ex instanceof Error
|
||||
? ex.message
|
||||
: typeof ex === "object" && ex !== null && "message" in ex
|
||||
? JSON.stringify(ex.message)
|
||||
: JSON.stringify(ex),
|
||||
name:
|
||||
ex instanceof Error
|
||||
? ex.name
|
||||
: typeof ex === "object" && ex !== null && "name" in ex
|
||||
? JSON.stringify(ex.name)
|
||||
: "Error",
|
||||
stack:
|
||||
ex instanceof Error
|
||||
? JSON.stringify(ex.stack)
|
||||
: typeof ex === "object" && ex !== null && "stack" in ex
|
||||
? JSON.stringify(ex.stack)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// adds an otel event
|
||||
@@ -90,7 +149,7 @@ export const traceException = (
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: { userId?: string; projectId?: string; email?: string },
|
||||
span?: opentelemetry.Span
|
||||
span?: opentelemetry.Span,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
@@ -113,7 +172,7 @@ export const recordGauge = (
|
||||
| {
|
||||
[tag: string]: string | number;
|
||||
}
|
||||
| undefined
|
||||
| undefined,
|
||||
) => {
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
@@ -121,7 +180,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);
|
||||
};
|
||||
@@ -129,7 +188,7 @@ 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);
|
||||
};
|
||||
|
||||
@@ -135,6 +135,37 @@ export async function fetchLLMCompletion(
|
||||
return await functionCallingModel.pipe(outputParser).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) {
|
||||
return chatModel.pipe(new BytesOutputParser()).stream(finalMessages);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { env } from "../env";
|
||||
import winston from "winston";
|
||||
import Transport from "winston-transport";
|
||||
import { getCurrentSpan } from "./instrumentation";
|
||||
|
||||
class TracedTransport extends Transport {
|
||||
constructor(opts: Transport.TransportStreamOptions = {}) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
log(info: Record<string, any>, callback: () => void): void {
|
||||
setImmediate(() => {
|
||||
this.emit("logged", info);
|
||||
});
|
||||
|
||||
const currentSpan = getCurrentSpan();
|
||||
info.trace_id = currentSpan?.spanContext().traceId;
|
||||
info.span_id = currentSpan?.spanContext().spanId;
|
||||
console.log(JSON.stringify(info));
|
||||
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
const getWinstonLogger = (
|
||||
nodeEnv: "development" | "production" | "test",
|
||||
minLevel = "info",
|
||||
) => {
|
||||
const textLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.align(),
|
||||
winston.format.printf((info) => {
|
||||
const logMessage = `${info.timestamp} ${info.level} ${info.message}`;
|
||||
return info.stack ? `${logMessage}\n${info.stack}` : logMessage;
|
||||
}),
|
||||
);
|
||||
|
||||
const jsonLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
const format =
|
||||
env.LANGFUSE_LOG_FORMAT === "text" ? textLoggerFormat : jsonLoggerFormat;
|
||||
const transport =
|
||||
env.LANGFUSE_LOG_FORMAT === "text"
|
||||
? new winston.transports.Console()
|
||||
: new TracedTransport();
|
||||
return winston.createLogger({
|
||||
level: minLevel,
|
||||
format: format,
|
||||
transports: [transport],
|
||||
});
|
||||
};
|
||||
|
||||
export const logger = getWinstonLogger(env.NODE_ENV, env.LANGFUSE_LOG_LEVEL);
|
||||
+9
-8
@@ -2,8 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { ColumnDefinition } from "./tableDefinitions/types";
|
||||
import type { OrderByState } from "./interfaces/orderBy";
|
||||
import type { ColumnDefinition } from "../tableDefinitions/types";
|
||||
import type { OrderByState } from "../interfaces/orderBy";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Convert orderBy to SQL ORDER BY clause
|
||||
@@ -13,20 +14,20 @@ import type { OrderByState } from "./interfaces/orderBy";
|
||||
*/
|
||||
export function orderByToPrismaSql(
|
||||
orderBy: OrderByState,
|
||||
tableColumns: ColumnDefinition[]
|
||||
tableColumns: ColumnDefinition[],
|
||||
): Prisma.Sql {
|
||||
if (!orderBy) {
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC`;
|
||||
}
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
// TODO: Only use id instead of name.
|
||||
// It's less error-prone & decouples data fetching from the human-readable UI labels
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column,
|
||||
);
|
||||
|
||||
if (!col) {
|
||||
console.log("Invalid filter column", orderBy.column);
|
||||
logger.warn("Invalid filter column", orderBy.column);
|
||||
throw new Error("Invalid filter column: " + orderBy.column);
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@ export function orderByToPrismaSql(
|
||||
const orderByOrder = z.enum(["ASC", "DESC"]);
|
||||
const order = orderByOrder.safeParse(orderBy.order);
|
||||
if (!order.success) {
|
||||
console.log("Invalid order", orderBy.order);
|
||||
logger.warn("Invalid order", orderBy.order);
|
||||
throw new Error("Invalid order: " + orderBy.order);
|
||||
}
|
||||
|
||||
// Both column and order are safe, can use raw SQL
|
||||
return Prisma.raw(
|
||||
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`
|
||||
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`,
|
||||
);
|
||||
}
|
||||
+22
-22
@@ -2,10 +2,10 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
|
||||
import { singleFilter } from "../interfaces/filters";
|
||||
import { orderBy } from "../interfaces/orderBy";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
import { sessionsViewCols } from "../tableDefinitions/index";
|
||||
import { sessionsViewCols } from "../../tableDefinitions";
|
||||
|
||||
const GetSessionTableSQLParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -16,8 +16,13 @@ const GetSessionTableSQLParamsSchema = z.object({
|
||||
});
|
||||
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
|
||||
|
||||
export const getSessionTableSQL = (
|
||||
params: GetSessionTableSQLParams
|
||||
export const createSessionsAllQuery = (
|
||||
select: Prisma.Sql,
|
||||
params: GetSessionTableSQLParams,
|
||||
options?: {
|
||||
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
|
||||
sessionIdList?: string[]; // used by session.metrics
|
||||
},
|
||||
): Prisma.Sql => {
|
||||
const { projectId, filter, orderBy, page, limit } =
|
||||
GetSessionTableSQLParamsSchema.parse(params);
|
||||
@@ -25,26 +30,17 @@ export const getSessionTableSQL = (
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
filter ?? [],
|
||||
sessionsViewCols,
|
||||
"sessions"
|
||||
"sessions",
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
|
||||
|
||||
const sessionIdFilter = options?.sessionIdList
|
||||
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
|
||||
: Prisma.sql``;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
s.id,
|
||||
s. "created_at" AS "createdAt",
|
||||
s.bookmarked,
|
||||
s.public,
|
||||
t. "userIds",
|
||||
t. "countTraces",
|
||||
o. "sessionDuration",
|
||||
o. "totalCost" AS "totalCost",
|
||||
o. "inputCost" AS "inputCost",
|
||||
o. "outputCost" AS "outputCost",
|
||||
o. "promptTokens" AS "promptTokens",
|
||||
o. "completionTokens" AS "completionTokens",
|
||||
o. "totalTokens" AS "totalTokens",
|
||||
(count(*) OVER ())::int AS "totalCount"
|
||||
${select}
|
||||
FROM
|
||||
trace_sessions AS s
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -54,9 +50,12 @@ export const getSessionTableSQL = (
|
||||
MIN(t. "timestamp") AS "min_timestamp",
|
||||
array_agg(t.id) AS "traceIds",
|
||||
array_agg(DISTINCT t.user_id) AS "userIds",
|
||||
count(t.id)::int AS "countTraces"
|
||||
count(t.id)::int AS "countTraces",
|
||||
array_agg(DISTINCT u.tag) AS "tags"
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
|
||||
WHERE
|
||||
t.project_id = ${projectId}
|
||||
AND t.session_id = s.id
|
||||
@@ -79,7 +78,8 @@ export const getSessionTableSQL = (
|
||||
WHERE
|
||||
s. "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
${sessionIdFilter}
|
||||
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit}
|
||||
`;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
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",
|
||||
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_view"
|
||||
WHERE
|
||||
trace_id = t.id
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
) AS tm 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"
|
||||
FROM
|
||||
"observations"
|
||||
WHERE
|
||||
trace_id = t.id
|
||||
AND "project_id" = ${projectId}
|
||||
${observationTimeseriesFilter}
|
||||
) AS tl 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}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
export { createTracesQuery } from "./createTracesQuery";
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
null;
|
||||
@@ -8,9 +8,11 @@ let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
export const getBatchExportQueue = () => {
|
||||
if (batchExportQueue) return batchExportQueue;
|
||||
|
||||
batchExportQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
batchExportQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Queue } from "bullmq";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { redis } from "../redis/redis";
|
||||
import { createNewRedisInstance } from "../redis/redis";
|
||||
import { QueueName } from "../queues";
|
||||
|
||||
export type IngestionFlushQueue = Queue<null>;
|
||||
@@ -11,9 +11,11 @@ let ingestionFlushQueue: IngestionFlushQueue | null = null;
|
||||
export const getIngestionFlushQueue = () => {
|
||||
if (ingestionFlushQueue) return ingestionFlushQueue;
|
||||
|
||||
ingestionFlushQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
ingestionFlushQueue = connection
|
||||
? new Queue<null>(QueueName.IngestionFlushQueue, {
|
||||
connection: redis,
|
||||
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,
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
export class LegacyIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null {
|
||||
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
|
||||
|
||||
legacyIngestionQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
|
||||
|
||||
LegacyIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
return LegacyIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import Redis from "ioredis";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const createNewRedisInstance = (
|
||||
additionalOptions: Partial<RedisOptions> = {},
|
||||
) => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: null;
|
||||
};
|
||||
|
||||
const createRedisClient = () => {
|
||||
try {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
})
|
||||
: null;
|
||||
return createNewRedisInstance();
|
||||
} catch (e) {
|
||||
console.error(e, "Failed to connect to redis");
|
||||
logger.error("Failed to connect to redis", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TraceUpsertEventType,
|
||||
} from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
@@ -14,9 +14,11 @@ let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
export const getTraceUpsertQueue = () => {
|
||||
if (traceUpsertQueue) return traceUpsertQueue;
|
||||
|
||||
traceUpsertQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
traceUpsertQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
|
||||
connection: redis,
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, PrismaClient } from "@prisma/client";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class PromptService {
|
||||
private cacheEnabled: boolean;
|
||||
@@ -11,7 +12,7 @@ export class PromptService {
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
cacheEnabled?: boolean, // used for testing
|
||||
) {
|
||||
this.cacheEnabled =
|
||||
Boolean(redis) &&
|
||||
@@ -25,7 +26,7 @@ export class PromptService {
|
||||
const cachedPrompt = await this.getCachedPrompt(params);
|
||||
|
||||
this.incrementMetric(
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss,
|
||||
);
|
||||
|
||||
if (cachedPrompt) {
|
||||
@@ -117,7 +118,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async lockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -133,7 +134,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async unlockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -149,7 +150,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private async isCacheLocked(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<boolean> {
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
@@ -163,14 +164,14 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getLockKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
|
||||
return `LOCK:${this.getCacheKeyPrefix(params)}`;
|
||||
}
|
||||
|
||||
public async invalidateCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -187,7 +188,7 @@ export class PromptService {
|
||||
await this.redis?.del([...(keys ?? []), keyIndexKey]);
|
||||
|
||||
this.logInfo(
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
|
||||
@@ -203,23 +204,23 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getCacheKeyPrefix(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private getKeyIndexKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt_key_index:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private logError(message: string, ...args: any[]) {
|
||||
console.error(`[PromptService] ${message}`, ...args);
|
||||
logger.error(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private logInfo(message: string, ...args: any[]) {
|
||||
console.log(`[PromptService] ${message}`, ...args);
|
||||
logger.info(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private incrementMetric(name: Metrics, value: number = 1) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Readable } from "stream";
|
||||
import { GetObjectCommand, 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";
|
||||
|
||||
type UploadFile = {
|
||||
fileName: string;
|
||||
@@ -56,15 +57,14 @@ export class S3StorageService {
|
||||
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
logger.error(err);
|
||||
throw new Error("Failed to upload to S3 or generate signed URL");
|
||||
}
|
||||
}
|
||||
|
||||
private async getSignedUrl(
|
||||
fileName: string,
|
||||
ttlSeconds: number
|
||||
ttlSeconds: number,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await getSignedUrl(
|
||||
@@ -74,7 +74,7 @@ export class S3StorageService {
|
||||
Key: fileName,
|
||||
ResponseContentDisposition: `attachment; filename="${fileName}"`,
|
||||
}),
|
||||
{ expiresIn: ttlSeconds }
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
} catch (err) {
|
||||
throw Error("Failed to generate signed URL");
|
||||
|
||||
+4
@@ -59,6 +59,10 @@ export const BatchExportSuccessEmailTemplate = ({
|
||||
download. The download link will expire in{" "}
|
||||
<strong>{expiresInHours} hours</strong>.
|
||||
</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
|
||||
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
|
||||
|
||||
+4
-4
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
type SendBatchExportSuccessParams = {
|
||||
env: Partial<
|
||||
@@ -24,8 +25,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
expiresInHours,
|
||||
}: SendBatchExportSuccessParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error("Missing environment variables for sending email.");
|
||||
|
||||
logger.error("Missing environment variables for sending email.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -51,6 +51,6 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
+7
-6
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
@@ -34,8 +35,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
orgName,
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
logger.error(
|
||||
"Missing environment variables for sending membership invitation email.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -49,8 +50,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
|
||||
const authUrl = getAuthURL();
|
||||
if (!authUrl) {
|
||||
console.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
|
||||
logger.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -77,6 +78,6 @@ export const sendMembershipInvitationEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "arrayOptions",
|
||||
internal: 't."userIds"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session Duration (s)",
|
||||
@@ -78,10 +79,18 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "number",
|
||||
internal: 'o."totalTokens"',
|
||||
},
|
||||
{
|
||||
name: "Trace Tags",
|
||||
id: "tags",
|
||||
type: "arrayOptions",
|
||||
internal: 't."tags"',
|
||||
options: [], // to be filled in at runtime
|
||||
},
|
||||
];
|
||||
|
||||
export type SessionOptions = {
|
||||
userIds: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function sessionsTableColsWithOptions(
|
||||
@@ -91,6 +100,9 @@ export function sessionsTableColsWithOptions(
|
||||
if (col.id === "userIds") {
|
||||
return { ...col, options: options?.userIds ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ColumnDefinition =
|
||||
id: string;
|
||||
type: "number" | "string" | "datetime" | "boolean";
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -16,6 +17,7 @@ export type ColumnDefinition =
|
||||
type: "stringOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -23,6 +25,7 @@ export type ColumnDefinition =
|
||||
type: "arrayOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -30,6 +33,7 @@ export type ColumnDefinition =
|
||||
type: "stringObject" | "numberObject";
|
||||
internal: string;
|
||||
keyOptions?: Array<string>;
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
export const tableNames = [
|
||||
|
||||
@@ -15,6 +15,7 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Timestamp",
|
||||
@@ -22,12 +23,19 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "datetime",
|
||||
internal: 't."timestamp"',
|
||||
},
|
||||
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "User ID",
|
||||
id: "userId",
|
||||
type: "string",
|
||||
internal: 't."user_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session ID",
|
||||
id: "sessionId",
|
||||
type: "string",
|
||||
internal: 't."session_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Metadata",
|
||||
@@ -40,12 +48,14 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 't."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Release",
|
||||
id: "release",
|
||||
type: "string",
|
||||
internal: 't."release"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Level",
|
||||
@@ -69,24 +79,28 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."promptTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."completionTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
id: "usage",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
{
|
||||
@@ -106,18 +120,21 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedInputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Cost ($)",
|
||||
id: "outputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedOutputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Cost ($)",
|
||||
id: "totalCost",
|
||||
type: "number",
|
||||
internal: '"calculatedTotalCost"',
|
||||
nullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
+2052
-1617
File diff suppressed because it is too large
Load Diff
+30
-9
@@ -33,15 +33,36 @@ 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_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
|
||||
|
||||
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
@@ -54,7 +75,7 @@ RUN rm -f ./web/src/middleware.ts
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
RUN turbo run build --filter=web
|
||||
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
|
||||
@@ -100,4 +121,4 @@ ENV PORT 3000
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command
|
||||
CMD ["node", "./web/server.js"]
|
||||
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
|
||||
|
||||
+62
-34
@@ -11,28 +11,51 @@ 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' https://*.langfuse.com https://*.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
style-src 'self' 'unsafe-inline' https://*.crisp.chat;
|
||||
img-src 'self' https: blob: data:;
|
||||
font-src 'self' https://*.crisp.chat;
|
||||
frame-src 'self' https://challenges.cloudflare.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;
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com https://game.crisp.chat;
|
||||
worker-src 'self' blob:;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
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;` : ""}
|
||||
`;
|
||||
|
||||
const reportToHeader = {
|
||||
key: "Report-To",
|
||||
value: JSON.stringify({
|
||||
group: "csp-endpoint",
|
||||
max_age: 10886400,
|
||||
endpoints: [
|
||||
{
|
||||
url: env.SENTRY_CSP_REPORT_URI,
|
||||
},
|
||||
],
|
||||
include_subdomains: true,
|
||||
}),
|
||||
};
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@langfuse/shared"],
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
serverComponentsExternalPackages: [
|
||||
"dd-trace",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/api",
|
||||
],
|
||||
},
|
||||
poweredByHeader: false,
|
||||
basePath: env.NEXT_PUBLIC_BASE_PATH,
|
||||
|
||||
/**
|
||||
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
|
||||
@@ -67,6 +90,7 @@ const nextConfig = {
|
||||
key: "Permissions-Policy",
|
||||
value: "autoplay=*, fullscreen=*, microphone=*",
|
||||
},
|
||||
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -117,10 +141,6 @@ const nextConfig = {
|
||||
|
||||
// webassembly support for @dqbd/tiktoken
|
||||
webpack(config, { isServer }) {
|
||||
if (isServer) {
|
||||
// https://github.com/open-telemetry/opentelemetry-js/issues/4173
|
||||
config.ignoreWarnings = [{ module: /opentelemetry/ }];
|
||||
}
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
@@ -130,36 +150,44 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const sentryOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// See the sections below for information on the following options:
|
||||
// 'Configure Source Maps':
|
||||
// - disableServerWebpackPlugin
|
||||
// - disableClientWebpackPlugin
|
||||
// - hideSourceMaps
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// 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",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
// - widenClientFileUpload
|
||||
// 'Configure Legacy Browser Support':
|
||||
// - transpileClientSDK
|
||||
// 'Configure Serverside Auto-instrumentation':
|
||||
// - autoInstrumentServerFunctions
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, sentryOptions);
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
});
|
||||
|
||||
+46
-39
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.74.0",
|
||||
"version": "2.80.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -27,6 +27,10 @@
|
||||
"@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",
|
||||
@@ -47,39 +51,40 @@
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/instrumentation-winston": "^0.36.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",
|
||||
"@opentelemetry/winston-transport": "^0.2.0",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@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",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@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-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "8.13.0",
|
||||
"@sentry/node": "8.13.0",
|
||||
"@sentry/nextjs": "8.26.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -89,16 +94,16 @@
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/codemirror-theme-github": "^4.23.0",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.2",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"bullmq": "^5.12.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"core-js": "^3.36.1",
|
||||
"core-js": "^3.38.1",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"dd-trace": "^5.21.0",
|
||||
@@ -108,24 +113,26 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.364.0",
|
||||
"next": "^14.2.3",
|
||||
"lucide-react": "^0.436.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"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",
|
||||
"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-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
@@ -134,7 +141,7 @@
|
||||
"sonner": "^1.4.41",
|
||||
"stripe": "^16.8.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-query-params": "^2.2.1",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -153,10 +160,10 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
@@ -164,17 +171,17 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
@@ -182,6 +189,6 @@
|
||||
"initVersion": "7.13.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"crisp-sdk-web": "^1.0.21"
|
||||
"crisp-sdk-web": "^1.0.25"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,31 @@ 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()],
|
||||
integrations: [
|
||||
Sentry.replayIntegration(),
|
||||
// Sentry.debugIntegration(),
|
||||
// Sentry.captureConsoleIntegration({
|
||||
// levels: ["error"],
|
||||
// }),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
tracesSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
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;
|
||||
},
|
||||
debug: false,
|
||||
|
||||
// ...
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString("base64");
|
||||
@@ -38,6 +37,14 @@ describe("Health endpoints", () => {
|
||||
});
|
||||
|
||||
describe("Ingestion Pipeline", () => {
|
||||
beforeEach(async () => {
|
||||
// clear the redis cache
|
||||
const keys = await redis?.keys("*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("ingest a trace", async () => {
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
@@ -118,7 +125,7 @@ describe("Ingestion Pipeline", () => {
|
||||
expect(redisKeys?.length).toBe(1);
|
||||
const redisValue = await redis?.get(redisKeys![0]);
|
||||
|
||||
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
|
||||
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
|
||||
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
@@ -143,6 +150,91 @@ describe("Ingestion Pipeline", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
}, 25000);
|
||||
|
||||
it("rate limit ingestion", async () => {
|
||||
// update the org in the database and set the rate limit to 1 for ingestion
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
});
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
...(typeof org?.cloudConfig === "object" ? org.cloudConfig : {}),
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 1,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
|
||||
const event = {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
name: "test trace",
|
||||
id: traceId,
|
||||
userId: "user-1", // triggers the eval
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "test span",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/ingestion";
|
||||
|
||||
// Act
|
||||
let responses = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
responses.push(
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// check that at least one of the responses is a 429
|
||||
const rateLimitedResponse = responses.find((r) => r.status === 429);
|
||||
expect(rateLimitedResponse).not.toBeNull();
|
||||
|
||||
// revert the rate limit on the org
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: org?.cloudConfig ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompts endpoint", () => {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
getDisplaySecretKey,
|
||||
hashSecretKey,
|
||||
OrgEnrichedApiKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
ApiAuthService,
|
||||
ApiKeyZod,
|
||||
} from "@/src/features/public-api/server/apiAuth";
|
||||
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Prisma, type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
describe("Authenticate API calls", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +66,118 @@ describe("Authenticate API calls", () => {
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should create new api key with stripe data and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
stripe: {
|
||||
customerId: "cus_test123",
|
||||
activeSubscriptionId: "sub_test123",
|
||||
activeProductId: "prod_test123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create new api key with custom rate limits and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
@@ -149,6 +259,25 @@ describe("Authenticate API calls", () => {
|
||||
it("should create new api key and read from cache", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// update the organization with a cloud config
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
@@ -177,12 +306,31 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
expect(cachedKey2).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey2!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("searching for non-existing key stores flag in redis and fails auth", async () => {
|
||||
@@ -275,7 +423,7 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -288,6 +436,8 @@ describe("Authenticate API calls", () => {
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
projectId: expect.any(String),
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,10 +522,12 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -396,6 +548,167 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidates api keys in redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("before each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
console.log("before each deleting keys. actually deleting", keys);
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("after each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should invalidate organization API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should invalidate project API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
const seedApiKey = {
|
||||
id: "seed-api-key",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { orderByToPrismaSql } from "@langfuse/shared/src/server";
|
||||
import { tracesTableCols } from "@langfuse/shared";
|
||||
|
||||
// The test for the orderByToPrisma function
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
describe("RateLimitService", () => {
|
||||
const orgId = "seed-org-id";
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(() => {
|
||||
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
expect(redis).toBeDefined();
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys?.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("should rate limit", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 999,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
|
||||
// check redis for the rate limit key
|
||||
const value = await redis.get("rate-limit:public-api:seed-org-id");
|
||||
|
||||
expect(value).toBeDefined();
|
||||
expect(parseInt(value ?? "0")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should increment the rate limit count", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 998,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the rate limit count after the window expires", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 100, durationInSec: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const firstResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
expect(firstResult?.isRateLimited()).toBe(false);
|
||||
|
||||
expect(firstResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 98,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const secondResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
|
||||
expect(secondResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 99,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(secondResult?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when rate limit is exceeded", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 60 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
}
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 0,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 6,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(true);
|
||||
});
|
||||
|
||||
it("should apply rate limits with override for specific resource", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 4,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not apply rate limits for resource prompts", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for ingestion when overridden to null in API key", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "ingestion" as const, points: null, durationInSec: null },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not apply rate limits when redis is not defined", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(null);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for OSS plan", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "oss" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type z } from "zod";
|
||||
|
||||
@@ -24,10 +21,7 @@ export const pruneDatabase = async () => {
|
||||
await prisma.llmApiKeys.deleteMany();
|
||||
};
|
||||
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
function createBasicAuthHeader(username: string, password: string): string {
|
||||
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
|
||||
"base64",
|
||||
);
|
||||
@@ -97,38 +91,3 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
|
||||
}
|
||||
return { body: resBody, status };
|
||||
}
|
||||
|
||||
export const setupUserAndProject = async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
name: "llm-app",
|
||||
apiKeys: {
|
||||
create: [
|
||||
{
|
||||
note: "seeded key",
|
||||
hashedSecretKey: await hashSecretKey("sk-lf-1234567890"),
|
||||
displaySecretKey: getDisplaySecretKey("sk-lf-1234567890"),
|
||||
publicKey: "pk-lf-1234567890",
|
||||
},
|
||||
],
|
||||
},
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, project };
|
||||
};
|
||||
|
||||
@@ -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-8 w-full pt-3 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}
|
||||
|
||||
@@ -24,13 +24,21 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
}, [scrollAreaRef, messages.length]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-4">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent {...{ message, ...props }} key={message.id} />
|
||||
);
|
||||
})}
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-2 font-semibold">Messages</div>
|
||||
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-3">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent
|
||||
{...{ message, ...props }}
|
||||
key={message.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<AddMessageButton {...props} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +60,10 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
<Button
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="w-full space-x-2 py-6"
|
||||
className="w-full"
|
||||
onClick={() => addMessage(nextMessageRole)}
|
||||
>
|
||||
<PlusCircleIcon size={16} />
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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,51 @@ export const LangfuseIcon = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const LangfuseLogotypeOrCustomized = ({ size }: { size: "sm" | "xl" }) => {
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
if (uiCustomization?.logoLightModeHref && uiCustomization?.logoDarkModeHref) {
|
||||
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 +78,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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
|
||||
<Link href={`/project/${p.projectId}/settings/api-keys`}>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
|
||||
@@ -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,15 +30,20 @@ export type ModelParamsContext = {
|
||||
) => void;
|
||||
setModelParamEnabled?: (key: keyof UIModelParams, enabled: boolean) => void;
|
||||
formDisabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
export const ModelParameters: React.FC<
|
||||
ModelParamsContext & { evalModelsOnly: boolean }
|
||||
> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
modelParamsDescription,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
|
||||
@@ -48,11 +54,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
No LLM API key set in project. For evals, we only support OpenAI so
|
||||
far.
|
||||
<p className="text-xs">
|
||||
No LLM API key set in project.{" "}
|
||||
{evalModelsOnly && "For evals, only OpenAI models are supported."}
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={true} />
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -71,7 +77,22 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
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"
|
||||
@@ -125,6 +146,7 @@ type ModelParamsSelectProps = {
|
||||
options: string[];
|
||||
updateModelParam: ModelParamsContext["updateModelParamValue"];
|
||||
disabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
};
|
||||
const ModelParamsSelect = ({
|
||||
title,
|
||||
@@ -133,6 +155,7 @@ const ModelParamsSelect = ({
|
||||
options,
|
||||
updateModelParam,
|
||||
disabled,
|
||||
modelParamsDescription,
|
||||
}: ModelParamsSelectProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -165,6 +188,9 @@ const ModelParamsSelect = ({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
<FormDescription>{modelParamsDescription}</FormDescription>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const PagedSettingsContainer = ({
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4 py-4 md:gap-8">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[150px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<nav className="block md:hidden">
|
||||
<Select
|
||||
onValueChange={(slug) => {
|
||||
|
||||
@@ -11,11 +11,11 @@ export const SettingsDangerZone: React.FC<{
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title="Danger Zone" level="h3" />
|
||||
<div className="rounded border">
|
||||
<div className="rounded-lg border">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
|
||||
className="flex items-center justify-between gap-4 border-b p-3 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold">{item.title}</h4>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadgeCheck,
|
||||
Github,
|
||||
HardDriveDownload,
|
||||
Info,
|
||||
Map,
|
||||
Newspaper,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
|
||||
@@ -30,6 +32,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
|
||||
});
|
||||
const isEeVersion = useIsEeEnabled();
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const hasUpdate =
|
||||
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
@@ -46,8 +49,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className={className}>
|
||||
<Button variant="ghost" size="xs" className={cn("text-xs",className)}>
|
||||
{VERSION}
|
||||
{!isLangfuseCloud && (isEeVersion ? " EE" : " OSS")}
|
||||
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -96,6 +100,17 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
Roadmap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isLangfuseCloud && !isEeVersion && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://langfuse.com/docs/deployment/feature-overview"
|
||||
target="_blank"
|
||||
>
|
||||
<Info size={16} className="mr-2" />
|
||||
Compare Versions
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -9,15 +9,14 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
|
||||
import {
|
||||
DEFAULT_AGGREGATION_SELECTION,
|
||||
tableDateRangeAggregationSettings,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type TableDateRangeOptions,
|
||||
DASHBOARD_AGGREGATION_OPTIONS,
|
||||
type DashboardDateRange,
|
||||
TABLE_RANGE_DROPDOWN_OPTIONS,
|
||||
TABLE_AGGREGATION_OPTIONS,
|
||||
getDateFromOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
@@ -101,24 +100,22 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
setDateRangeAndOption,
|
||||
}) => {
|
||||
const onDropDownSelection = (value: TableDateRangeOptions) => {
|
||||
if (value === DEFAULT_AGGREGATION_SELECTION) {
|
||||
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
tableDateRangeAggregationSettings[
|
||||
value as keyof typeof tableDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting),
|
||||
to: new Date(),
|
||||
const dateFromOption = getDateFromOption({
|
||||
filterSource: "TABLE",
|
||||
option: value,
|
||||
});
|
||||
|
||||
const initialDateRange = !!dateFromOption
|
||||
? { from: dateFromOption, to: new Date() }
|
||||
: undefined;
|
||||
|
||||
setDateRangeAndOption(value, initialDateRange);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={TABLE_RANGE_DROPDOWN_OPTIONS}
|
||||
options={TABLE_AGGREGATION_OPTIONS}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function JsonEditor({
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
className={cn("overflow-hidden rounded-md border", className)}
|
||||
className={cn("overflow-hidden rounded-md border text-xs", className)}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
export const FullScreenPage: React.FC<React.PropsWithChildren<{}>> = ({
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const FullScreenPage: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
mobile?: boolean;
|
||||
className?: string;
|
||||
lgHeight?: string;
|
||||
mobileHeight?: string;
|
||||
}>
|
||||
> = ({
|
||||
children,
|
||||
mobile = true,
|
||||
className,
|
||||
lgHeight = "lg:h-[calc(100dvh-1.5rem)]",
|
||||
mobileHeight = "h-[calc(100dvh-6rem)]",
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-6rem)] flex-col overflow-hidden lg:h-[calc(100dvh-2rem)]">
|
||||
<div
|
||||
className={cn(
|
||||
`flex flex-col overflow-hidden ${lgHeight}`,
|
||||
mobile && mobileHeight,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
@@ -212,7 +222,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
@@ -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>
|
||||
@@ -299,7 +309,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -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 = [
|
||||
@@ -118,6 +117,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const cloudAdmin =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
|
||||
session.data?.user?.admin === true;
|
||||
|
||||
// project info based on projectId in the URL
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
|
||||
@@ -134,7 +137,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
if (
|
||||
route.featureFlag !== undefined &&
|
||||
!enableExperimentalFeatures &&
|
||||
session.data?.user?.admin !== true &&
|
||||
!cloudAdmin &&
|
||||
session.data?.user?.featureFlags[route.featureFlag] !== true
|
||||
)
|
||||
return null;
|
||||
@@ -143,14 +146,14 @@ export default function Layout(props: PropsWithChildren) {
|
||||
if (
|
||||
route.entitlement !== undefined &&
|
||||
!entitlements.includes(route.entitlement) &&
|
||||
session.data?.user?.admin !== true
|
||||
!cloudAdmin
|
||||
)
|
||||
return null;
|
||||
|
||||
// RBAC
|
||||
if (
|
||||
route.projectRbacScope !== undefined &&
|
||||
session.data?.user?.admin !== true &&
|
||||
!cloudAdmin &&
|
||||
(!project ||
|
||||
!organization ||
|
||||
!hasProjectAccess({
|
||||
@@ -160,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)[] =
|
||||
@@ -346,7 +364,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition.Root>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-52 lg:flex-col">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex h-screen grow flex-col border-r border-border bg-background">
|
||||
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-4 py-3">
|
||||
@@ -418,7 +436,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -486,7 +504,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="mb-1 block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -511,12 +529,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="lg:pl-56">
|
||||
<div className="lg:pl-52">
|
||||
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
|
||||
<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" />
|
||||
@@ -532,7 +550,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<main className="p-4">{props.children}</main>
|
||||
<main className="p-3">{props.children}</main>
|
||||
<Toaster visibleToasts={1} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,6 +577,8 @@ const MainNavigation: React.FC<{
|
||||
{},
|
||||
);
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
return (
|
||||
<li className={className}>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
@@ -576,34 +596,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
|
||||
@@ -634,7 +664,7 @@ const MainNavigation: React.FC<{
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 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",
|
||||
@@ -648,7 +678,7 @@ const MainNavigation: React.FC<{
|
||||
open
|
||||
? "rotate-90 text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
"ml-auto h-5 w-5 shrink-0",
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -663,7 +693,7 @@ const MainNavigation: React.FC<{
|
||||
subItem.current
|
||||
? "bg-primary-foreground text-primary-accent"
|
||||
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1 pl-7 pr-2 text-sm",
|
||||
)}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
|
||||
@@ -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[] = [
|
||||
@@ -39,7 +47,8 @@ export const ROUTES: Route[] = [
|
||||
name: "Langfuse",
|
||||
pathname: "/",
|
||||
icon: LangfuseIcon,
|
||||
label: <VersionLabel />,
|
||||
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",
|
||||
|
||||
@@ -53,10 +53,10 @@ export const SessionPage: React.FC<{
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const commentCounts = api.comments.getCountsByObjectIds.useQuery(
|
||||
const commentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectIds: [sessionId],
|
||||
objectId: sessionId,
|
||||
objectType: "SESSION",
|
||||
},
|
||||
{ enabled: session.isSuccess },
|
||||
@@ -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"
|
||||
|
||||
@@ -13,13 +13,13 @@ export function StarToggle({
|
||||
value,
|
||||
disabled = false,
|
||||
onClick,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
isLoading,
|
||||
}: {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (value: boolean) => Promise<unknown>;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -40,18 +40,19 @@ export function StarToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the trace table
|
||||
export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
tracesFilter: RouterInput["traces"]["all"];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -89,7 +90,6 @@ export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return {
|
||||
totalCount: oldQueryData?.totalCount,
|
||||
traces: oldQueryData?.traces
|
||||
? oldQueryData.traces.map((trace) => {
|
||||
return {
|
||||
@@ -130,16 +130,17 @@ export function StarTraceToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the single trace view
|
||||
export function StarTraceDetailsToggle({
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -153,12 +154,15 @@ export function StarTraceDetailsToggle({
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
await utils.traces.byId.cancel();
|
||||
await utils.traces.byIdWithObservationsAndScores.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prevData = utils.traces.byId.getData({ traceId, projectId });
|
||||
const prevData = utils.traces.byIdWithObservationsAndScores.getData({
|
||||
traceId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
return { prevData };
|
||||
},
|
||||
@@ -166,14 +170,21 @@ export function StarTraceDetailsToggle({
|
||||
setIsLoading(false);
|
||||
trpcErrorToast(err);
|
||||
// Rollback to the previous value if mutation fails
|
||||
utils.traces.byId.setData({ traceId, projectId }, context?.prevData);
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
context?.prevData,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
|
||||
utils.traces.byId.setData(
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
|
||||
(
|
||||
oldQueryData:
|
||||
| RouterOutput["traces"]["byIdWithObservationsAndScores"]
|
||||
| undefined,
|
||||
) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
...oldQueryData,
|
||||
@@ -182,7 +193,7 @@ export function StarTraceDetailsToggle({
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
void utils.traces.byId.invalidate();
|
||||
void utils.traces.byIdWithObservationsAndScores.invalidate();
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -212,12 +223,12 @@ export function StarSessionToggle({
|
||||
projectId,
|
||||
sessionId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
@@ -26,6 +29,22 @@ export function DataTablePagination<TData>({
|
||||
paginationOptions = [10, 20, 30, 40, 50],
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const [inputState, setInputState] = useState<number | string>(currentPage);
|
||||
|
||||
useEffect(() => {
|
||||
setInputState(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const pageCount = table.getPageCount();
|
||||
const setPageIndex = table.setPageIndex;
|
||||
useEffect(() => {
|
||||
if (currentPage > pageCount && pageCount > 0) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [currentPage, pageCount, setPageIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
@@ -61,10 +80,60 @@ export function DataTablePagination<TData>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center whitespace-nowrap text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{Math.max(table.getPageCount(), 1)}
|
||||
<div className="flex items-center justify-center gap-1 whitespace-nowrap text-sm font-medium">
|
||||
{table.getPageCount() !== -1 ? (
|
||||
<>
|
||||
Page
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pageCount}
|
||||
value={inputState} // Ensure the value is within bounds
|
||||
onChange={(e) => {
|
||||
setInputState(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const newValue = e.target.value;
|
||||
if (newValue === "") {
|
||||
table.setPageIndex(0);
|
||||
setInputState(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// if nan, reset to current page
|
||||
if (isNaN(Number(newValue))) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPageIndex = Number(newValue) - 1;
|
||||
if (newPageIndex < 0 || newPageIndex >= pageCount) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
table.setPageIndex(newPageIndex);
|
||||
setInputState(newPageIndex + 1);
|
||||
}}
|
||||
className="h-8 appearance-none"
|
||||
style={{
|
||||
width: `${3 + Math.max(1, pageCount.toString().length)}ch`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
`Page ${currentPage}`
|
||||
)}
|
||||
{pageCount !== -1 ? (
|
||||
<span>of {pageCount}</span>
|
||||
) : (
|
||||
<span>
|
||||
of{" "}
|
||||
<LoaderCircle className="ml-1 inline-block h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -112,12 +181,12 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
table.setPageIndex(pageCount - 1);
|
||||
capture("table:pagination_button_click", {
|
||||
type: "lastPage",
|
||||
});
|
||||
}}
|
||||
disabled={!table.getCanNextPage()}
|
||||
disabled={!table.getCanNextPage() || pageCount === -1}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
|
||||
@@ -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,
|
||||
@@ -31,9 +34,13 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
searchConfig?: SearchConfig;
|
||||
actionButtons?: React.ReactNode;
|
||||
filterState?: FilterState;
|
||||
setFilterState?: Dispatch<SetStateAction<FilterState>>;
|
||||
setFilterState?:
|
||||
| Dispatch<SetStateAction<FilterState>>
|
||||
| ((newState: FilterState) => void);
|
||||
columnVisibility?: VisibilityState;
|
||||
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
|
||||
columnOrder?: ColumnOrderState;
|
||||
setColumnOrder?: Dispatch<SetStateAction<ColumnOrderState>>;
|
||||
rowHeight?: RowHeight;
|
||||
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
|
||||
columnsWithCustomSelect?: string[];
|
||||
@@ -53,6 +60,8 @@ export function DataTableToolbar<TData, TValue>({
|
||||
setFilterState,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnOrder,
|
||||
setColumnOrder,
|
||||
rowHeight,
|
||||
setRowHeight,
|
||||
columnsWithCustomSelect,
|
||||
@@ -79,7 +88,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="h-10 w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -113,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,
|
||||
@@ -36,7 +37,7 @@ interface DataTableProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
pagination?: {
|
||||
pageCount: number;
|
||||
totalCount: number | null; // null if loading
|
||||
onChange: OnChangeFn<PaginationState>;
|
||||
state: PaginationState;
|
||||
options?: number[];
|
||||
@@ -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,14 +100,33 @@ 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,
|
||||
pageCount: pagination?.pageCount ?? 0,
|
||||
pageCount:
|
||||
pagination?.totalCount === null ||
|
||||
pagination?.state.pageSize === undefined
|
||||
? -1
|
||||
: Math.ceil(
|
||||
Number(pagination?.totalCount) / pagination?.state.pageSize,
|
||||
),
|
||||
onPaginationChange: pagination?.onChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: onColumnVisibilityChange,
|
||||
@@ -105,6 +141,9 @@ export function DataTable<TData extends object, TValue>({
|
||||
columnFilters,
|
||||
pagination: pagination?.state,
|
||||
columnVisibility,
|
||||
columnOrder: columnOrder
|
||||
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
|
||||
: undefined,
|
||||
rowSelection,
|
||||
},
|
||||
manualFiltering: true,
|
||||
@@ -165,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}
|
||||
@@ -175,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;
|
||||
@@ -213,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;
|
||||
@@ -296,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> {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TableLink({
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-0.5 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
className,
|
||||
)}
|
||||
href={path}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user