Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8245cf8ae7 | ||
|
|
05b3021bff | ||
|
|
44bcebdaec | ||
|
|
2ac15e8714 | ||
|
|
13b027cd3c | ||
|
|
4a89cf4e45 | ||
|
|
e776af544f | ||
|
|
0d781f12f3 | ||
|
|
5d81505d84 | ||
|
|
8471b7f7b3 | ||
|
|
78a90afdab | ||
|
|
9e304ad873 | ||
|
|
5457053fdf | ||
|
|
e91716c6a2 | ||
|
|
5148cfa690 | ||
|
|
86816e569d | ||
|
|
0fe2176930 | ||
|
|
43f7c524dc | ||
|
|
2bcfab130d | ||
|
|
51bb1c3374 | ||
|
|
302bb065b7 | ||
|
|
e6a8fb84f0 | ||
|
|
b12f3df933 | ||
|
|
159b5f87fe | ||
|
|
73fe2dc1a7 | ||
|
|
9daf348d61 | ||
|
|
8a9d0b01af | ||
|
|
315d8c7619 | ||
|
|
b23eaf3cd4 | ||
|
|
eea1e8d054 | ||
|
|
06a7a4bfb2 | ||
|
|
7c8a30d48f | ||
|
|
1971adda18 | ||
|
|
d9b4e3a7dd | ||
|
|
f1aa884cbc | ||
|
|
0ed9acddb6 | ||
|
|
61e3470a93 | ||
|
|
b21dd45597 | ||
|
|
c1649ce9d1 | ||
|
|
555ac90535 | ||
|
|
a84d826e22 | ||
|
|
7464059c6e | ||
|
|
f159afc49e | ||
|
|
e064f13dca | ||
|
|
8ec44d7fdb | ||
|
|
9358f21b2b | ||
|
|
4e5a3c0e07 | ||
|
|
2950a480f1 | ||
|
|
b4246ec66e | ||
|
|
dc15b72fc6 | ||
|
|
1ccfe68aae | ||
|
|
1977725efc | ||
|
|
ac444e68e9 | ||
|
|
ba58d6412c | ||
|
|
5ceadd189c | ||
|
|
4c56e69702 | ||
|
|
857cf123a4 | ||
|
|
7af78553d1 | ||
|
|
aba41aeb1c | ||
|
|
1e56085d86 | ||
|
|
f39576ccb4 | ||
|
|
408dd15d85 | ||
|
|
7a00e9415c | ||
|
|
f9dda6088f | ||
|
|
d5d570ddc8 | ||
|
|
f37dc514a5 | ||
|
|
2ec0ae8db2 | ||
|
|
3a6f441524 | ||
|
|
afec6fb433 | ||
|
|
27e61c6f51 | ||
|
|
27868582f9 | ||
|
|
e6e740839f | ||
|
|
8dc6ad601b | ||
|
|
1f38258e0c | ||
|
|
c42ff44f8f | ||
|
|
ec86cc7fe2 | ||
|
|
d213aa54a6 | ||
|
|
2a2f72cdc8 | ||
|
|
b25b841a36 | ||
|
|
0190d54815 | ||
|
|
d8d2a3c00c | ||
|
|
7f9a739ca5 | ||
|
|
65f797095e | ||
|
|
1ac5934e2a | ||
|
|
adf362c282 | ||
|
|
77e2687baa | ||
|
|
52a3276495 | ||
|
|
cd6f9606e8 | ||
|
|
e03b51657d | ||
|
|
02266d420e | ||
|
|
3b0e856410 | ||
|
|
d0a619af35 | ||
|
|
7594d3a214 | ||
|
|
ec76d94e4a | ||
|
|
f6615cc052 | ||
|
|
d0d0df3829 | ||
|
|
85d890529f | ||
|
|
91876f0082 | ||
|
|
4c65011cb6 | ||
|
|
7d0b7d4301 | ||
|
|
5d1064f5de | ||
|
|
b76d15df9d | ||
|
|
af0e57e561 | ||
|
|
ca9377ea4e | ||
|
|
9f3ac3ab9f | ||
|
|
e38deed204 | ||
|
|
19bf90dd71 | ||
|
|
0523fbfc67 | ||
|
|
0fcdac0cf1 | ||
|
|
c2bc17d4d1 | ||
|
|
7d11812fd1 | ||
|
|
877bad2234 | ||
|
|
dd90a28d1c |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "langfuse dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"moby": true
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "cp .env.dev.example .env && pnpm run dx-f"
|
||||
}
|
||||
+1
-2
@@ -50,5 +50,4 @@ REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=6c16874e5c0f0cc74ddec00425fa99fbe9ffbe412b7d5d906a4b00005df91403
|
||||
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@ REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
+40
-17
@@ -10,36 +10,38 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# SHADOW_DATABASE_URL=
|
||||
# optional, set to true to disable automated database migrations on Docker start
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
# LANGFUSE_AUTO_POSTGRES_MIGRATION_DISABLED=
|
||||
|
||||
# Next Auth
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# You can generate a new secret on the command line with:
|
||||
# For each of these, you can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
NEXTAUTH_SECRET="secret"
|
||||
SALT="salt"
|
||||
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
|
||||
SALT="salt" # salt used to hash api keys
|
||||
|
||||
# API level encryption for sensitive data
|
||||
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
|
||||
# Docker only, optional
|
||||
# PORT=3000
|
||||
# HOSTNAME=localhost
|
||||
|
||||
# Default project, optional
|
||||
# Default role for users who sign up, optional, can be org or org+project
|
||||
# LANGFUSE_DEFAULT_ORG_ID=
|
||||
# LANGFUSE_DEFAULT_ORG_ROLE=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
# Disable automated database migrations on Docker start, optional
|
||||
# LANFUSE_WEB_MIGRATION_DISABLED=true
|
||||
|
||||
# Auth, optional configuration
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
@@ -95,8 +97,20 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Disable logging to `events` table in db
|
||||
# ENABLE_EVENT_LOG=false
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
|
||||
# Allowlisted users that can create new organizations, by default all users can create organizations
|
||||
# LANGFUSE_ALLOWED_ORGANIZATION_CREATORS=user1@langfuse.com,user2@langfuse.com
|
||||
|
||||
# UI Customization Options
|
||||
# LANGFUSE_UI_API_HOST=https://api.example.com
|
||||
# LANGFUSE_UI_DOCUMENTATION_HREF=https://docs.example.com
|
||||
# LANGFUSE_UI_SUPPORT_HREF=https://support.example.com
|
||||
# LANGFUSE_UI_FEEDBACK_HREF=https://feedback.example.com
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
|
||||
|
||||
@@ -125,12 +139,13 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# 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=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
@@ -138,14 +153,12 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Demo project that users can use to try the platform
|
||||
# NEXT_PUBLIC_DEMO_ORG_ID=
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
# Crisp chat
|
||||
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
|
||||
|
||||
# Encryption
|
||||
# ENCRYPTION_KEY=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
@@ -154,6 +167,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
@@ -161,6 +175,13 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
@@ -181,5 +202,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
+16
-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,25 @@ 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"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
- "@opentelemetry/*"
|
||||
- "@appsignal/opentelemetry-instrumentation-bullmq"
|
||||
- "@prisma/instrumentation"
|
||||
- "@sentry/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
grep -v '^REDIS_HOST=' .env.dev.example > web/.env
|
||||
grep -v -e '^REDIS_HOST=' .env.dev.example > .env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -136,6 +136,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 +166,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:
|
||||
@@ -219,10 +228,62 @@ jobs:
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e
|
||||
|
||||
e2e-server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
|
||||
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
|
||||
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose ps
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed:examples
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run server
|
||||
run: (pnpm run start&)
|
||||
|
||||
- name: Run e2e tests
|
||||
run: pnpm --filter=web run test:e2e:server
|
||||
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, tests-web, tests-worker, e2e-tests, test-docker-build]
|
||||
needs:
|
||||
[
|
||||
lint,
|
||||
tests-web,
|
||||
tests-worker,
|
||||
e2e-tests,
|
||||
test-docker-build,
|
||||
e2e-server-tests,
|
||||
]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Successful deploy
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
- production
|
||||
name: Deploy to web
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
- production
|
||||
name: Deploy to worker (EU temp)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4060"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 3be075c0-b121-4123-aec1-5dc571626b0c
|
||||
PORTER_HOST: https://dashboard.porter.run
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
name: Snyk Container
|
||||
on: push
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -18,7 +25,7 @@ jobs:
|
||||
args: --file=web/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: web
|
||||
@@ -33,7 +40,7 @@ jobs:
|
||||
args: --file=worker/Dockerfile
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: worker
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||

|
||||

|
||||
|
||||
<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">
|
||||
<div>
|
||||
@@ -37,7 +41,7 @@
|
||||
</div>
|
||||
</br>
|
||||
|
||||
## Overview
|
||||
## Langfuse Overview
|
||||
|
||||
_Unmute video for voice-over_
|
||||
|
||||
@@ -45,18 +49,18 @@ https://github.com/langfuse/langfuse/assets/2834609/a94062e9-c782-4ee9-af59-dee6
|
||||
|
||||
### Develop
|
||||
|
||||
- **Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **LLM Observability:** Instrument your app and start ingesting traces to Langfuse ([Quickstart](https://langfuse.com/docs/get-started), [Integrations](https://langfuse.com/docs/integrations) [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Langfuse UI:** Inspect and debug complex logs ([Demo](https://langfuse.com/docs/demo), [Tracing](https://langfuse.com/docs/tracing))
|
||||
- **Prompt Management:** Manage, version and deploy prompts from within Langfuse ([Prompt Management](https://langfuse.com/docs/prompts/get-started))
|
||||
- **Prompt Engineering:** Test and iterate on your prompts with the [LLM Playground](https://langfuse.com/docs/playground)
|
||||
|
||||
### Monitor
|
||||
|
||||
- **Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **Evals:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run model-based evaluations ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) within Langfuse
|
||||
- **LLM Analytics:** Track metrics (cost, latency, quality) and gain insights from dashboards & data exports ([Analytics](https://langfuse.com/docs/analytics))
|
||||
- **LLM Evaluations:** Collect and calculate scores for your LLM completions ([Scores & Evaluations](https://langfuse.com/docs/scores))
|
||||
- Run ([Model-based evaluations](https://langfuse.com/docs/scores/model-based-evals)) and LLM-as-a-Judge within Langfuse
|
||||
- Collect user feedback ([User Feedback](https://langfuse.com/docs/scores/user-feedback))
|
||||
- Manually score observations in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
- Manually score LLM outputs in Langfuse ([Manual Scores](https://langfuse.com/docs/scores/manually))
|
||||
|
||||
### Test
|
||||
|
||||
@@ -72,6 +76,8 @@ Managed deployment by the Langfuse team, generous free-tier (hobby plan), no cre
|
||||
|
||||
**[» Langfuse Cloud](https://cloud.langfuse.com)**
|
||||
|
||||
## Self-Hosting Open Source LLM Observability with Langfuse
|
||||
|
||||
### Localhost (docker)
|
||||
|
||||
```bash
|
||||
@@ -98,7 +104,7 @@ Templated deployments: [Railway, GCP Cloud Run, AWS Fargate, Kubernetes and othe
|
||||
|
||||
You need a Langfuse public and secret key to get started. Sign up [here](https://cloud.langfuse.com) and find them in your project settings.
|
||||
|
||||
### Ingesting Data · Instrumenting Your Application
|
||||
### Ingesting Data · Instrumenting Your Application · LLM Observability with Langfuse
|
||||
|
||||
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.
|
||||
|
||||
@@ -106,7 +112,7 @@ You may want to use another integration to get started quickly or implement a us
|
||||
|
||||
See the [→ Quickstart](https://langfuse.com/docs/get-started) to integrate Langfuse.
|
||||
|
||||
### Integrations
|
||||
### LLM Observability Integrations
|
||||
|
||||
| Integration | Supports | Description |
|
||||
| -------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -132,9 +138,9 @@ Packages that integrate with Langfuse:
|
||||
|
||||
### Ideas and roadmap
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/idea)
|
||||
- [Roadmap](https://langfuse.com/roadmap)
|
||||
- [GitHub Discussions](https://github.com/orgs/langfuse/discussions)
|
||||
- [Feature Requests](https://langfuse.com/idea
|
||||
|
||||
### Support and feedback
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ Join us in scaling Langfuse in Berlin, Germany. We are an open source company, w
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Backend Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mnrdwla-backend-engineer
|
||||
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"next": "^14.2.3",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { env } from "../env";
|
||||
|
||||
export const isEeAvailable: boolean =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined;
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ||
|
||||
env.LANGFUSE_EE_LICENSE_KEY !== undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env as sharedEnv } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { isEeAvailable } from "./ee-license-check";
|
||||
|
||||
@@ -51,7 +51,7 @@ service:
|
||||
response: PromptMetaListResponse
|
||||
|
||||
create:
|
||||
docs: Create a prompt
|
||||
docs: Create a new version for the prompt with the given `name`
|
||||
method: POST
|
||||
path: /prompts
|
||||
request: CreatePromptRequest
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.68.0",
|
||||
"version": "2.75.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -18,6 +18,7 @@
|
||||
"nuke": "bash ./scripts/nuke.sh",
|
||||
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev": "turbo run dev",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,22 +50,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.550.0",
|
||||
"@aws-sdk/client-s3": "^3.627.0",
|
||||
"@aws-sdk/lib-storage": "^3.568.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.554.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@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",
|
||||
"ioredis": "^5.4.1",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
@@ -73,7 +76,7 @@
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
"zod-to-json-schema": "^3.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
@@ -91,7 +94,7 @@
|
||||
"kysely-codegen": "^0.11.0",
|
||||
"nodemon": "^3.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.13.0",
|
||||
"prisma": "^5.17.0",
|
||||
"prisma-erd-generator": "^1.11.2",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -4,13 +4,14 @@ export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export const ProjectRole = {
|
||||
export const Role = {
|
||||
OWNER: "OWNER",
|
||||
ADMIN: "ADMIN",
|
||||
MEMBER: "MEMBER",
|
||||
VIEWER: "VIEWER"
|
||||
VIEWER: "VIEWER",
|
||||
NONE: "NONE"
|
||||
} as const;
|
||||
export type ProjectRole = (typeof ProjectRole)[keyof typeof ProjectRole];
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
export const ObservationType = {
|
||||
SPAN: "SPAN",
|
||||
EVENT: "EVENT",
|
||||
@@ -41,6 +42,13 @@ export const DatasetStatus = {
|
||||
ARCHIVED: "ARCHIVED"
|
||||
} as const;
|
||||
export type DatasetStatus = (typeof DatasetStatus)[keyof typeof DatasetStatus];
|
||||
export const CommentObjectType = {
|
||||
TRACE: "TRACE",
|
||||
OBSERVATION: "OBSERVATION",
|
||||
SESSION: "SESSION",
|
||||
PROMPT: "PROMPT"
|
||||
} as const;
|
||||
export type CommentObjectType = (typeof CommentObjectType)[keyof typeof CommentObjectType];
|
||||
export const JobType = {
|
||||
EVAL: "EVAL"
|
||||
} as const;
|
||||
@@ -90,8 +98,10 @@ export type AuditLog = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
user_id: string;
|
||||
project_id: string;
|
||||
user_project_role: ProjectRole;
|
||||
org_id: string;
|
||||
user_org_role: string;
|
||||
project_id: string | null;
|
||||
user_project_role: string | null;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
@@ -113,6 +123,16 @@ export type BatchExport = {
|
||||
url: string | null;
|
||||
log: string | null;
|
||||
};
|
||||
export type Comment = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
object_type: CommentObjectType;
|
||||
object_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
content: string;
|
||||
author_user_id: string | null;
|
||||
};
|
||||
export type CronJobs = {
|
||||
name: string;
|
||||
last_run: Timestamp | null;
|
||||
@@ -229,9 +249,11 @@ export type LlmApiKeys = {
|
||||
export type MembershipInvitation = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ProjectRole;
|
||||
project_id: string;
|
||||
sender_id: string | null;
|
||||
org_id: string;
|
||||
org_role: Role;
|
||||
project_id: string | null;
|
||||
project_role: Role | null;
|
||||
invited_by_user_id: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
@@ -321,6 +343,21 @@ export type ObservationView = {
|
||||
latency: number | null;
|
||||
time_to_first_token: number | null;
|
||||
};
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
cloud_config: unknown | null;
|
||||
};
|
||||
export type OrganizationMembership = {
|
||||
id: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
role: Role;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type PosthogIntegration = {
|
||||
project_id: string;
|
||||
encrypted_posthog_api_key: string;
|
||||
@@ -331,15 +368,16 @@ export type PosthogIntegration = {
|
||||
};
|
||||
export type Project = {
|
||||
id: string;
|
||||
org_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
name: string;
|
||||
cloud_config: unknown | null;
|
||||
};
|
||||
export type ProjectMembership = {
|
||||
org_membership_id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
role: ProjectRole;
|
||||
role: Role;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
@@ -470,6 +508,7 @@ export type DB = {
|
||||
api_keys: ApiKey;
|
||||
audit_logs: AuditLog;
|
||||
batch_exports: BatchExport;
|
||||
comments: Comment;
|
||||
cron_jobs: CronJobs;
|
||||
dataset_items: DatasetItem;
|
||||
dataset_run_items: DatasetRunItems;
|
||||
@@ -484,6 +523,8 @@ export type DB = {
|
||||
models: Model;
|
||||
observations: Observation;
|
||||
observations_view: ObservationView;
|
||||
organization_memberships: OrganizationMembership;
|
||||
organizations: Organization;
|
||||
posthog_integrations: PosthogIntegration;
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
-- gpt-4o-2024-08-06
|
||||
('clzjr85f70000ymmzg7hqffra', NULL, 'gpt-4o-2024-08-06', '(?i)^(gpt-4o-2024-08-06)$', NULL, 0.0000025, 0.000010, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
-- Table ORGANIZATIONS
|
||||
-- Create empty table ORGANIZATIONS
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cloud_config" JSONB,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
-- Backfill table ORGANIZATIONS: create a new organization for each project, move cloudConfig, and set the org_id on the project to project_id prefixed with 'o'
|
||||
INSERT INTO "organizations" ("id", "name", "cloud_config", "created_at")
|
||||
SELECT
|
||||
CONCAT('o', "id") as "id", -- This mapping is used in other migration steps as well, keep it consistent
|
||||
"name",
|
||||
"cloud_config",
|
||||
"created_at"
|
||||
FROM "projects";
|
||||
-- Drop column cloud_config from projects as it's now on organization level
|
||||
ALTER TABLE "projects" DROP COLUMN "cloud_config";
|
||||
|
||||
|
||||
-- PROJECT.ORG_ID
|
||||
-- Add org_id to projects
|
||||
ALTER TABLE "projects" ADD COLUMN "org_id" TEXT;
|
||||
-- Backfill: org_id on projects, set it to project_id prefixed with 'o'
|
||||
UPDATE "projects"
|
||||
SET "org_id" = CONCAT('o', "id");
|
||||
-- Set not null after backfill
|
||||
ALTER TABLE "projects" ALTER COLUMN "org_id" SET NOT NULL;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_org_id_idx" ON "projects"("org_id");
|
||||
|
||||
|
||||
-- ORGANIZATION MEMBERSHIPS
|
||||
-- Create UserRole ENUM
|
||||
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER', 'NONE');
|
||||
-- Create empty table
|
||||
CREATE TABLE "organization_memberships" (
|
||||
"id" TEXT NOT NULL,
|
||||
"org_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organization_memberships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
-- Add indexes
|
||||
CREATE INDEX "organization_memberships_user_id_idx" ON "organization_memberships"("user_id");
|
||||
CREATE INDEX "organization_memberships_org_id_idx" ON "organization_memberships"("org_id");
|
||||
CREATE UNIQUE INDEX "organization_memberships_org_id_user_id_key" ON "organization_memberships"("org_id", "user_id");
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "organization_memberships" ADD CONSTRAINT "organization_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
|
||||
-- Migrate project memberships to organization memberships
|
||||
INSERT INTO "organization_memberships" ("id", "org_id", "user_id", "role", "created_at", "updated_at")
|
||||
SELECT
|
||||
md5(random()::text || clock_timestamp()::text || project_id::text || user_id::text)::uuid AS "id",
|
||||
CONCAT('o', "project_id") as "org_id",
|
||||
"user_id",
|
||||
"role"::text::"Role" as "role",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "project_memberships";
|
||||
|
||||
|
||||
|
||||
-- Delete all project memberships after migration to organization memberships
|
||||
DELETE FROM "project_memberships";
|
||||
|
||||
-- Add org_membership_id to project_memberships, ok to be not null as it's a new column on a now empty table
|
||||
ALTER TABLE "project_memberships" ADD COLUMN "org_membership_id" TEXT NOT NULL;
|
||||
-- Switch to new UserRole enum
|
||||
ALTER TABLE "project_memberships" DROP COLUMN "role",
|
||||
ADD COLUMN "role" "Role" NOT NULL;
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_org_membership_id_fkey" FOREIGN KEY ("org_membership_id") REFERENCES "organization_memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "project_memberships_project_id_idx" ON "project_memberships"("project_id");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "project_memberships_org_membership_id_idx" ON "project_memberships"("org_membership_id");
|
||||
|
||||
|
||||
|
||||
-- AUDIT LOGS
|
||||
-- DropForeignKey, just index on these, should remain after project/org/user deletions
|
||||
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_user_id_fkey";
|
||||
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_project_id_fkey";
|
||||
-- Drop not null on project level cols
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "user_project_role" DROP NOT NULL;
|
||||
-- Add org-level cols
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "org_id" TEXT;
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "user_org_role" TEXT;
|
||||
-- Backfill org_id
|
||||
UPDATE "audit_logs"
|
||||
SET "org_id" = "projects"."org_id"
|
||||
FROM "projects"
|
||||
WHERE "audit_logs"."project_id" = "projects"."id";
|
||||
-- Backfill user_org_role with value from user_project_role
|
||||
UPDATE "audit_logs"
|
||||
SET "user_org_role" = "user_project_role"::text::"Role";
|
||||
-- Drop and recreate user_project_role column as text column going forward, empty for historical data as it's all on org level now
|
||||
ALTER TABLE "audit_logs" DROP COLUMN "user_project_role";
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "user_project_role" TEXT; -- nullable
|
||||
-- Add not null on org level cols
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "org_id" SET NOT NULL;
|
||||
ALTER TABLE "audit_logs" ALTER COLUMN "user_org_role" SET NOT NULL;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
|
||||
CREATE INDEX "audit_logs_org_id_idx" ON "audit_logs"("org_id");
|
||||
|
||||
|
||||
-- MEMBERSHIP INVITATIONS
|
||||
-- Rename sender_id to invited_by_user_id
|
||||
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_sender_id_fkey";
|
||||
ALTER TABLE "membership_invitations" RENAME COLUMN "sender_id" TO "invited_by_user_id";
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_invited_by_user_id_fkey" FOREIGN KEY ("invited_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "membership_invitations" DROP CONSTRAINT "membership_invitations_project_id_fkey";
|
||||
-- AlterTable
|
||||
ALTER TABLE "membership_invitations" RENAME COLUMN "role" TO "project_role";
|
||||
ALTER TABLE "membership_invitations" ADD COLUMN "org_id" TEXT;
|
||||
ALTER TABLE "membership_invitations" ADD COLUMN "org_role" "Role";
|
||||
ALTER TABLE "membership_invitations" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||
ALTER TABLE "membership_invitations" ALTER COLUMN "project_role" DROP NOT NULL;
|
||||
-- Backfill org id
|
||||
UPDATE "membership_invitations"
|
||||
SET "org_id" = "projects"."org_id"
|
||||
FROM "projects"
|
||||
WHERE "membership_invitations"."project_id" = "projects"."id";
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- Backfill org role with value from project role
|
||||
UPDATE "membership_invitations"
|
||||
SET "org_role" = "project_role"::text::"Role";
|
||||
-- Set project-level cols to null, as it's now org level for all existing invitations and role enum will change below
|
||||
UPDATE "membership_invitations"
|
||||
SET "project_role" = NULL, "project_id" = NULL;
|
||||
-- Switch to new UserRole enum
|
||||
ALTER TABLE "membership_invitations" DROP COLUMN "project_role",
|
||||
ADD COLUMN "project_role" "Role";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Add not null on org level cols after backfill
|
||||
ALTER TABLE "membership_invitations"
|
||||
ALTER COLUMN "org_id" SET NOT NULL,
|
||||
ALTER COLUMN "org_role" SET NOT NULL;
|
||||
-- CreateIndex
|
||||
CREATE INDEX "membership_invitations_org_id_idx" ON "membership_invitations"("org_id");
|
||||
|
||||
|
||||
-- Drop ProjectRole enum as it is replaced by Role
|
||||
DROP TYPE "ProjectRole";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
|
||||
-- Fix model name
|
||||
-- https://github.com/langfuse/langfuse/issues/2688
|
||||
|
||||
UPDATE models
|
||||
SET model_name = 'text-embedding-3-large'
|
||||
WHERE id = 'clruwn76700020al7gp8e4g4l'
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_observation_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dataset_items" DROP CONSTRAINT "dataset_items_source_trace_id_fkey";
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CommentObjectType" AS ENUM ('TRACE', 'OBSERVATION', 'SESSION', 'PROMPT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"object_type" "CommentObjectType" NOT NULL,
|
||||
"object_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"content" TEXT NOT NULL,
|
||||
"author_user_id" TEXT,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comments_project_id_object_type_object_id_idx" ON "comments"("project_id", "object_type", "object_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -63,21 +63,21 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
AuditLog AuditLog[]
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -91,13 +91,27 @@ model VerificationToken {
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see @/src/features/organizations/utils/cloudConfigSchema
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projects Project[]
|
||||
MembershipInvitation MembershipInvitation[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
name String
|
||||
cloudConfig Json? @map("cloud_config") // Langfuse Cloud, for zod schema see projectsRouter.ts
|
||||
projectMembers ProjectMembership[]
|
||||
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
traces Trace[]
|
||||
observations Observation[]
|
||||
apiKeys ApiKey[]
|
||||
@@ -107,7 +121,6 @@ model Project {
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
Model Model[]
|
||||
AuditLog AuditLog[]
|
||||
EvalTemplate EvalTemplate[]
|
||||
JobConfiguration JobConfiguration[]
|
||||
JobExecution JobExecution[]
|
||||
@@ -116,7 +129,9 @@ model Project {
|
||||
Score Score[]
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
comment Comment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
@@ -161,41 +176,68 @@ model LlmApiKeys {
|
||||
@@map("llm_api_keys")
|
||||
}
|
||||
|
||||
model OrganizationMembership {
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @map("role")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
ProjectMemberships ProjectMembership[]
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@index([userId])
|
||||
@@index([orgId])
|
||||
@@map("organization_memberships")
|
||||
}
|
||||
|
||||
// Set a project-specific role for a user in an organization
|
||||
model ProjectMembership {
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role ProjectRole
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
orgMembershipId String @map("org_membership_id")
|
||||
organizationMembership OrganizationMembership @relation(fields: [orgMembershipId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@id([projectId, userId])
|
||||
@@index([userId])
|
||||
@@index([projectId])
|
||||
@@index([orgMembershipId])
|
||||
@@map("project_memberships")
|
||||
}
|
||||
|
||||
model MembershipInvitation {
|
||||
id String @id @unique @default(cuid())
|
||||
email String
|
||||
role ProjectRole
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
senderId String? @map("sender_id")
|
||||
sender User? @relation(fields: [senderId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
id String @id @unique @default(cuid())
|
||||
email String
|
||||
orgId String @map("org_id")
|
||||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgRole Role @map("org_role")
|
||||
projectId String? @map("project_id")
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
projectRole Role? @map("project_role")
|
||||
invitedByUserId String? @map("invited_by_user_id")
|
||||
invitedByUser User? @relation(fields: [invitedByUserId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId])
|
||||
@@index([orgId])
|
||||
@@index([email])
|
||||
@@map("membership_invitations")
|
||||
}
|
||||
|
||||
enum ProjectRole {
|
||||
enum Role {
|
||||
OWNER
|
||||
ADMIN
|
||||
MEMBER
|
||||
VIEWER
|
||||
NONE
|
||||
}
|
||||
|
||||
model TraceSession {
|
||||
@@ -238,7 +280,6 @@ model Trace {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
DatasetItem DatasetItem[]
|
||||
JobExecution JobExecution[]
|
||||
|
||||
@@index([projectId])
|
||||
@@ -323,9 +364,8 @@ model Observation {
|
||||
calculatedOutputCost Decimal? @map("calculated_output_cost")
|
||||
calculatedTotalCost Decimal? @map("calculated_total_cost")
|
||||
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
derivedDatasetItems DatasetItem[]
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
promptId String? @map("prompt_id") // no fk constraint, prompt can be deleted
|
||||
|
||||
@@ -518,9 +558,7 @@ model DatasetItem {
|
||||
expectedOutput Json? @map("expected_output")
|
||||
metadata Json?
|
||||
sourceTraceId String? @map("source_trace_id")
|
||||
sourceTrace Trace? @relation(fields: [sourceTraceId], references: [id], onDelete: SetNull)
|
||||
sourceObservationId String? @map("source_observation_id")
|
||||
sourceObservation Observation? @relation(fields: [sourceObservationId], references: [id], onDelete: SetNull)
|
||||
datasetId String @map("dataset_id")
|
||||
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -598,6 +636,28 @@ model Events {
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
objectType CommentObjectType @map("object_type")
|
||||
objectId String @map("object_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
content String
|
||||
authorUserId String? @map("author_user_id") // no fk constraint, user can be deleted
|
||||
|
||||
@@index([projectId, objectType, objectId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
enum CommentObjectType {
|
||||
TRACE
|
||||
OBSERVATION
|
||||
SESSION
|
||||
PROMPT
|
||||
}
|
||||
|
||||
model Prompt {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -653,22 +713,25 @@ model Model {
|
||||
@@map("models")
|
||||
}
|
||||
|
||||
// No FK constraints to preserve audit logs
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userProjectRole ProjectRole @map("user_project_role")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId String @map("user_id")
|
||||
orgId String @map("org_id")
|
||||
userOrgRole String @map("user_org_role")
|
||||
projectId String? @map("project_id")
|
||||
userProjectRole String? @map("user_project_role")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
action String
|
||||
before String? //stringified JSON
|
||||
after String? // stringified JSON
|
||||
|
||||
@@index([projectId])
|
||||
@@index([userId])
|
||||
@@index([orgId])
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@map("audit_logs")
|
||||
|
||||
+239
-56
@@ -34,39 +34,111 @@ async function main() {
|
||||
options,
|
||||
}).values.environment;
|
||||
|
||||
const seedOrgId = "seed-org-id";
|
||||
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
const seedUserId1 = "user-1"; // Owner of org
|
||||
const seedUserId2 = "user-2"; // Member of org, admin of project
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "user-1" },
|
||||
where: { id: seedUserId1 },
|
||||
update: {
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
create: {
|
||||
id: "user-1",
|
||||
id: seedUserId1,
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
image: "https://static.langfuse.com/langfuse-dev%2Fexample-avatar.png",
|
||||
},
|
||||
});
|
||||
const user2 = await prisma.user.upsert({
|
||||
where: { id: seedUserId2 },
|
||||
update: {
|
||||
name: "Demo User 2",
|
||||
email: "member@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
create: {
|
||||
id: seedUserId2,
|
||||
name: "Demo User 2",
|
||||
email: "member@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
});
|
||||
|
||||
const seedProjectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
await prisma.organization.upsert({
|
||||
where: { id: seedOrgId },
|
||||
update: {
|
||||
name: "Seed Org",
|
||||
},
|
||||
create: {
|
||||
id: seedOrgId,
|
||||
name: "Seed Org",
|
||||
},
|
||||
});
|
||||
|
||||
const project1 = await prisma.project.upsert({
|
||||
where: { id: seedProjectId },
|
||||
update: {
|
||||
name: "llm-app",
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
create: {
|
||||
id: seedProjectId,
|
||||
name: "llm-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgId,
|
||||
role: "OWNER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const orgMembership2 = await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user2.id,
|
||||
orgId: seedOrgId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user2.id,
|
||||
orgId: seedOrgId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const projectMembership = await prisma.projectMembership.upsert({
|
||||
where: {
|
||||
projectId_userId: {
|
||||
projectId: project1.id,
|
||||
userId: user2.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user2.id,
|
||||
projectId: project1.id,
|
||||
role: "ADMIN",
|
||||
orgMembershipId: orgMembership2.id,
|
||||
},
|
||||
update: {
|
||||
orgMembershipId: orgMembership2.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.upsert({
|
||||
@@ -114,18 +186,39 @@ async function main() {
|
||||
|
||||
// Do not run the following for local docker compose setup
|
||||
if (environment === "examples" || environment === "load") {
|
||||
const project2 = await prisma.project.upsert({
|
||||
where: { id: "239ad00f-562f-411d-af14-831c75ddd875" },
|
||||
const seedOrgIdOrg2 = "demo-org-id";
|
||||
const project2Id = "239ad00f-562f-411d-af14-831c75ddd875";
|
||||
const org2 = await prisma.organization.upsert({
|
||||
where: { id: seedOrgIdOrg2 },
|
||||
update: {
|
||||
name: "Langfuse Demo",
|
||||
},
|
||||
create: {
|
||||
id: "239ad00f-562f-411d-af14-831c75ddd875",
|
||||
id: seedOrgIdOrg2,
|
||||
name: "Langfuse Demo",
|
||||
},
|
||||
});
|
||||
const project2 = await prisma.project.upsert({
|
||||
where: { id: project2Id },
|
||||
create: {
|
||||
id: project2Id,
|
||||
name: "demo-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
orgId: org2.id,
|
||||
},
|
||||
update: { orgId: seedOrgIdOrg2 },
|
||||
});
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgIdOrg2,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
orgId: seedOrgIdOrg2,
|
||||
role: "VIEWER",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
@@ -164,21 +257,29 @@ async function main() {
|
||||
|
||||
const traceVolume = environment === "load" ? LOAD_TRACE_VOLUME : 100;
|
||||
|
||||
const { traces, observations, scores, sessions, events } = createObjects(
|
||||
traceVolume,
|
||||
envTags,
|
||||
colorTags,
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
);
|
||||
const { traces, observations, scores, sessions, events, comments } =
|
||||
createObjects(
|
||||
traceVolume,
|
||||
envTags,
|
||||
colorTags,
|
||||
project1,
|
||||
project2,
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
|
||||
);
|
||||
|
||||
await uploadObjects(traces, observations, scores, sessions, events);
|
||||
await uploadObjects(
|
||||
traces,
|
||||
observations,
|
||||
scores,
|
||||
sessions,
|
||||
events,
|
||||
comments
|
||||
);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
@@ -371,7 +472,8 @@ async function uploadObjects(
|
||||
observations: Prisma.ObservationCreateManyInput[],
|
||||
scores: Prisma.ScoreCreateManyInput[],
|
||||
sessions: Prisma.TraceSessionCreateManyInput[],
|
||||
events: Prisma.ObservationCreateManyInput[]
|
||||
events: Prisma.ObservationCreateManyInput[],
|
||||
comments: Prisma.CommentCreateManyInput[]
|
||||
) {
|
||||
let promises: Prisma.PrismaPromise<unknown>[] = [];
|
||||
|
||||
@@ -463,6 +565,22 @@ async function uploadObjects(
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
|
||||
promises = [];
|
||||
chunk(comments, chunkSize).forEach((chunk) => {
|
||||
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`
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
}
|
||||
|
||||
function createObjects(
|
||||
@@ -488,6 +606,7 @@ function createObjects(
|
||||
const sessions: Prisma.TraceSessionCreateManyInput[] = [];
|
||||
const events: Prisma.ObservationCreateManyInput[] = [];
|
||||
const configs: Prisma.ScoreConfigCreateManyInput[] = [];
|
||||
const comments: Prisma.CommentCreateManyInput[] = [];
|
||||
|
||||
for (let i = 0; i < traceVolume; i++) {
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
@@ -518,6 +637,7 @@ function createObjects(
|
||||
const trace = {
|
||||
id: `trace-${v4()}`,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
projectId: projectId,
|
||||
name: ["generate-outreach", "label-inbound", "draft-response"][
|
||||
i % 3
|
||||
@@ -576,6 +696,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: annotationScoreName,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
projectId,
|
||||
authorUserId: `user-${i}`,
|
||||
@@ -592,6 +713,7 @@ function createObjects(
|
||||
name: "sentiment",
|
||||
value: Math.floor(Math.random() * 10) - 5,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
@@ -604,6 +726,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: "Completeness",
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
@@ -614,6 +737,15 @@ function createObjects(
|
||||
: []),
|
||||
];
|
||||
|
||||
if (Math.random() > 0.9)
|
||||
comments.push({
|
||||
projectId: trace.projectId,
|
||||
objectId: trace.id,
|
||||
objectType: "TRACE",
|
||||
content: "Trace comment content",
|
||||
...(Math.random() > 0.5 ? { authorUserId: `user-${i}` } : {}),
|
||||
});
|
||||
|
||||
scores.push(...traceScores);
|
||||
|
||||
const existingSpanIds: string[] = [];
|
||||
@@ -632,6 +764,7 @@ function createObjects(
|
||||
type: ObservationType.SPAN,
|
||||
id: `span-${v4()}`,
|
||||
startTime: spanTsStart,
|
||||
createdAt: spanTsStart,
|
||||
endTime: spanTsEnd,
|
||||
name: `span-${i}-${j}`,
|
||||
metadata: {
|
||||
@@ -699,45 +832,21 @@ function createObjects(
|
||||
)
|
||||
];
|
||||
|
||||
const { input, output } = getGenerationInputOutput();
|
||||
|
||||
const generation = {
|
||||
type: ObservationType.GENERATION,
|
||||
id: `generation-${v4()}`,
|
||||
startTime: generationTsStart,
|
||||
createdAt: generationTsStart,
|
||||
endTime: generationTsEnd,
|
||||
completionStartTime:
|
||||
Math.random() > 0.5 ? generationTsCompletionStart : undefined,
|
||||
name: `generation-${i}-${j}-${k}`,
|
||||
projectId: trace.projectId,
|
||||
promptId: promptId,
|
||||
input:
|
||||
Math.random() > 0.5
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: "Be a helpful assistant",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a *React* component?",
|
||||
},
|
||||
]
|
||||
: {
|
||||
input: "How can i create a React component?",
|
||||
retrievedDocuments: [
|
||||
{
|
||||
title: "How to create a React component",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
{
|
||||
title: "React component creation",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
],
|
||||
},
|
||||
output:
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.",
|
||||
input,
|
||||
output,
|
||||
model: model,
|
||||
internalModel: model,
|
||||
modelParameters: {
|
||||
@@ -773,6 +882,8 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
createdAt: traceTs,
|
||||
});
|
||||
if (Math.random() > 0.6)
|
||||
scores.push({
|
||||
@@ -782,6 +893,16 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
createdAt: traceTs,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.8)
|
||||
comments.push({
|
||||
projectId: trace.projectId,
|
||||
objectId: generation.id,
|
||||
objectType: "OBSERVATION",
|
||||
content: "Observation comment content",
|
||||
});
|
||||
|
||||
for (let l = 0; l < Math.floor(Math.random() * 2); l++) {
|
||||
@@ -797,6 +918,7 @@ function createObjects(
|
||||
type: ObservationType.EVENT,
|
||||
id: `event-${v4()}`,
|
||||
startTime: eventTs,
|
||||
createdAt: eventTs,
|
||||
name: `event-${i}-${j}-${k}-${l}`,
|
||||
metadata: {
|
||||
user: `user-${i}@langfuse.com`,
|
||||
@@ -821,6 +943,7 @@ function createObjects(
|
||||
configs,
|
||||
sessions: uniqueSessions,
|
||||
events,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1095,3 +1218,63 @@ async function generateConfigs(project: Project) {
|
||||
|
||||
return configNameAndId;
|
||||
}
|
||||
function getGenerationInputOutput(): {
|
||||
input: Prisma.InputJsonValue;
|
||||
output: Prisma.InputJsonValue;
|
||||
} {
|
||||
if (Math.random() > 0.9) {
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "What’s depicted in this image?", type: "text" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
|
||||
},
|
||||
},
|
||||
{ text: "Describe the scene in detail.", type: "text" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const output =
|
||||
"The image depicts a serene landscape featuring a wooden pathway or boardwalk that winds through a lush green field. The field is filled with tall grass and surrounded by trees and shrubs. Above, the sky is bright with scattered clouds, suggesting a clear and pleasant day. The scene conveys a sense of tranquility and natural beauty.";
|
||||
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
const input =
|
||||
Math.random() > 0.5
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: "Be a helpful assistant",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "How can i create a *React* component?",
|
||||
},
|
||||
]
|
||||
: {
|
||||
input: "How can i create a React component?",
|
||||
retrievedDocuments: [
|
||||
{
|
||||
title: "How to create a React component",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
{
|
||||
title: "React component creation",
|
||||
url: "https://www.google.com",
|
||||
description: "A guide to creating React components",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const output =
|
||||
"Creating a React component can be done in two ways: as a functional component or as a class component. Let's start with a basic example of both.\n\n**Image**\n\n\n\n1. **Functional Component**:\n\nA functional component is just a plain JavaScript function that accepts props as an argument, and returns a React element. Here's how you can create one:\n\n```javascript\nimport React from 'react';\nfunction Greeting(props) {\n return <h1>Hello, {props.name}</h1>;\n}\nexport default Greeting;\n```\n\nTo use this component in another file, you can do:\n\n```javascript\nimport Greeting from './Greeting';\nfunction App() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n}\nexport default App;\n```\n\n2. **Class Component**:\n\nYou can also define components as classes in React. These have some additional features compared to functional components:\n\n```javascript\nimport React, { Component } from 'react';\nclass Greeting extends Component {\n render() {\n return <h1>Hello, {this.props.name}</h1>;\n }\n}\nexport default Greeting;\n```\n\nAnd here's how to use this component:\n\n```javascript\nimport Greeting from './Greeting';\nclass App extends Component {\n render() {\n return (\n <div>\n <Greeting name=\"John\" />\n </div>\n );\n }\n}\nexport default App;\n```\n\nWith the advent of hooks in React, functional components can do everything that class components can do and hence, the community has been favoring functional components over class components.\n\nRemember to import React at the top of your file whenever you're creating a component, because JSX transpiles to `React.createElement` calls under the hood.";
|
||||
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { env } from "../env";
|
||||
|
||||
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const IV_LENGTH: number = 16; // For AES, this is always 16
|
||||
|
||||
// Alternatively: openssl rand -hex 32
|
||||
@@ -13,17 +13,16 @@ export function keyGen() {
|
||||
* Encrypts the given plain text using AES-256-GCM algorithm.
|
||||
*
|
||||
* @param {string} plainText - The text to encrypt.
|
||||
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
|
||||
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
|
||||
*/
|
||||
export function encrypt(
|
||||
plainText: string,
|
||||
keyHex: string = ENCRYPTION_KEY
|
||||
): string {
|
||||
export function encrypt(plainText: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
let encrypted = cipher.update(plainText, "utf8", "hex");
|
||||
@@ -34,7 +33,10 @@ export function encrypt(
|
||||
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
|
||||
}
|
||||
|
||||
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
export function decrypt(text: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = text.split(":");
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error("Invalid or corrupted cipher format");
|
||||
@@ -46,7 +48,7 @@ export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
@@ -16,6 +16,7 @@ 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(
|
||||
@@ -28,6 +29,15 @@ const EnvSchema = z.object({
|
||||
CLICKHOUSE_URL: z.string().url().optional(),
|
||||
CLICKHOUSE_USER: z.string().optional(),
|
||||
CLICKHOUSE_PASSWORD: z.string().optional(),
|
||||
LANGFUSE_INGESTION_FLUSH_DELAY_MS: z.coerce
|
||||
.number()
|
||||
.nonnegative()
|
||||
.default(10000),
|
||||
LANGFUSE_INGESTION_FLUSH_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
LANGFUSE_INGESTION_BUFFER_TTL_SECONDS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const COMMENT_OBJECT_TYPES = [
|
||||
"TRACE",
|
||||
"OBSERVATION",
|
||||
"SESSION",
|
||||
"PROMPT",
|
||||
] as const;
|
||||
|
||||
export const CreateCommentData = z.object({
|
||||
projectId: z.string(),
|
||||
content: z.string().trim().min(1).max(500),
|
||||
objectId: z.string(),
|
||||
objectType: z.enum(COMMENT_OBJECT_TYPES),
|
||||
});
|
||||
|
||||
export const DeleteCommentData = z.object({
|
||||
projectId: z.string(),
|
||||
commentId: z.string(),
|
||||
objectId: z.string(),
|
||||
objectType: z.enum(COMMENT_OBJECT_TYPES),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export const planLabels = {
|
||||
oss: "OSS",
|
||||
"cloud:hobby": "Hobby",
|
||||
"cloud:pro": "Pro",
|
||||
"cloud:team": "Team",
|
||||
"self-hosted:enterprise": "Enterprise",
|
||||
} as const;
|
||||
|
||||
export type Plan = keyof typeof planLabels;
|
||||
|
||||
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);
|
||||
@@ -81,6 +81,7 @@ export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
|
||||
[LLMAdapter.OpenAI]: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-2024-08-06", // cheaper than gpt-4o
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-3.5-turbo",
|
||||
] as const,
|
||||
|
||||
+14
-12
@@ -1,12 +1,13 @@
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ScoreConfig as ScoreConfigDbType } from "@prisma/client";
|
||||
|
||||
import { isPresent } from "../../utils/typeChecks";
|
||||
import {
|
||||
jsonSchema,
|
||||
paginationMetaResponseZod,
|
||||
paginationZod,
|
||||
type ScoreConfig as ScoreConfigDbType,
|
||||
} from "@langfuse/shared";
|
||||
import { z } from "zod";
|
||||
import * as Sentry from "@sentry/node";
|
||||
} from "../../utils/zod";
|
||||
|
||||
/**
|
||||
* Types to use across codebase
|
||||
@@ -16,7 +17,7 @@ export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
|
||||
|
||||
const validateCategories = (
|
||||
categories: ConfigCategory[],
|
||||
ctx: z.RefinementCtx,
|
||||
ctx: z.RefinementCtx
|
||||
) => {
|
||||
const uniqueNames = new Set<string>();
|
||||
const uniqueValues = new Set<number>();
|
||||
@@ -93,7 +94,7 @@ const BooleanScoreConfig = z.object({
|
||||
return categories.every(
|
||||
(category, index) =>
|
||||
category.label === expectedCategories[index].label &&
|
||||
category.value === expectedCategories[index].value,
|
||||
category.value === expectedCategories[index].value
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -122,7 +123,7 @@ const ValidatedScoreConfigSchema = z
|
||||
minValue: z.undefined().nullish(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
categories: Categories.superRefine(validateCategories),
|
||||
}),
|
||||
})
|
||||
),
|
||||
ScoreConfigBase.merge(BooleanScoreConfig),
|
||||
])
|
||||
@@ -143,19 +144,20 @@ const ValidatedScoreConfigSchema = z
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of score configs from the database before using in the application to ensure type safety.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
|
||||
* All score configs are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scoreConfigs
|
||||
* @returns list of validated score configs
|
||||
*/
|
||||
export const filterAndValidateDbScoreConfigList = (
|
||||
scoreConfigs: ScoreConfigDbType[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): ValidatedScoreConfig[] =>
|
||||
scoreConfigs.reduce((acc, ts) => {
|
||||
const result = ValidatedScoreConfigSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as ValidatedScoreConfig[]);
|
||||
@@ -168,7 +170,7 @@ export const filterAndValidateDbScoreConfigList = (
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScoreConfig = (
|
||||
scoreConfig: ScoreConfigDbType,
|
||||
scoreConfig: ScoreConfigDbType
|
||||
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
|
||||
|
||||
/**
|
||||
@@ -203,7 +205,7 @@ export const PostScoreConfigBody = z
|
||||
z.object({
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
categories: z.undefined().nullish(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
+33
-30
@@ -1,20 +1,19 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import {
|
||||
paginationZod,
|
||||
paginationMetaResponseZod,
|
||||
NonEmptyString,
|
||||
type Score,
|
||||
} from "@langfuse/shared";
|
||||
import { stringDateTime } from "@langfuse/shared/src/server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
import { Category as ConfigCategory } from "./score-configs";
|
||||
|
||||
import { Score } from "@prisma/client";
|
||||
|
||||
import { isPresent, stringDateTime } from "../../utils/typeChecks";
|
||||
import {
|
||||
NonEmptyString,
|
||||
paginationMetaResponseZod,
|
||||
paginationZod,
|
||||
} from "../../utils/zod";
|
||||
import { Category as ConfigCategory } from "./scoreConfigTypes";
|
||||
|
||||
/**
|
||||
* Types to use across codebase
|
||||
*/
|
||||
export type APIScore = z.infer<typeof APIScore>;
|
||||
export type APIScore = z.infer<typeof APIScoreSchema>;
|
||||
|
||||
/**
|
||||
* Helpers
|
||||
@@ -70,7 +69,7 @@ const BaseScoreBody = z.object({
|
||||
* Objects
|
||||
*/
|
||||
|
||||
export const APIScore = z.discriminatedUnion("dataType", [
|
||||
export const APIScoreSchema = z.discriminatedUnion("dataType", [
|
||||
ScoreBase.merge(NumericData),
|
||||
ScoreBase.merge(CategoricalData),
|
||||
ScoreBase.merge(BooleanData),
|
||||
@@ -84,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({
|
||||
@@ -98,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -156,18 +155,21 @@ export const ScorePropsAgainstConfig = z.union([
|
||||
|
||||
/**
|
||||
* Use this function when pulling a list of scores from the database before using in the application to ensure type safety.
|
||||
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Sentry.
|
||||
* All scores are expected to pass the validation. If a score fails validation, it will be logged to Otel.
|
||||
* @param scores
|
||||
* @returns list of validated scores
|
||||
*/
|
||||
export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
|
||||
export const filterAndValidateDbScoreList = (
|
||||
scores: Score[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): APIScore[] =>
|
||||
scores.reduce((acc, ts) => {
|
||||
const result = APIScore.safeParse(ts);
|
||||
const result = APIScoreSchema.safeParse(ts);
|
||||
if (result.success) {
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
console.error("Score parsing error: ", result.error);
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
}, [] as APIScore[]);
|
||||
@@ -180,7 +182,7 @@ export const filterAndValidateDbScoreList = (scores: Score[]): APIScore[] =>
|
||||
* @throws error if score fails validation
|
||||
*/
|
||||
export const validateDbScore = (score: Score): APIScore =>
|
||||
APIScore.parse(score);
|
||||
APIScoreSchema.parse(score);
|
||||
|
||||
/**
|
||||
* Endpoints
|
||||
@@ -196,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({
|
||||
@@ -213,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(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -249,12 +251,12 @@ export const GetScoresQuery = z.object({
|
||||
|
||||
// LegacyGetScoreResponseDataV1 is only used for response of GET /scores list endpoint
|
||||
const LegacyGetScoreResponseDataV1 = z.intersection(
|
||||
APIScore,
|
||||
APIScoreSchema,
|
||||
z.object({
|
||||
trace: z.object({
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
export const GetScoresResponse = z.object({
|
||||
data: z.array(LegacyGetScoreResponseDataV1),
|
||||
@@ -263,6 +265,7 @@ export const GetScoresResponse = z.object({
|
||||
|
||||
export const legacyFilterAndValidateV1GetScoreList = (
|
||||
scores: unknown[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
|
||||
scores.reduce(
|
||||
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
|
||||
@@ -271,11 +274,11 @@ export const legacyFilterAndValidateV1GetScoreList = (
|
||||
acc.push(result.data);
|
||||
} else {
|
||||
console.error("Score parsing error: ", result.error);
|
||||
Sentry.captureException(result.error);
|
||||
onParseError?.(result.error);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
|
||||
);
|
||||
|
||||
// GET /scores/{scoreId}
|
||||
@@ -283,7 +286,7 @@ export const GetScoreQuery = z.object({
|
||||
scoreId: z.string(),
|
||||
});
|
||||
|
||||
export const GetScoreResponse = APIScore;
|
||||
export const GetScoreResponse = APIScoreSchema;
|
||||
|
||||
// DELETE /scores/{scoreId}
|
||||
export const DeleteScoreQuery = z.object({
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators } from "./interfaces/filters";
|
||||
import { filterOperators, timeFilter } from "./interfaces/filters";
|
||||
import { z } from "zod";
|
||||
|
||||
const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
@@ -179,3 +180,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;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
export * from "./constants";
|
||||
export * from "./queries";
|
||||
export * from "./queues";
|
||||
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";
|
||||
export * from "./server/auth/auth";
|
||||
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
|
||||
@@ -26,6 +30,13 @@ export * from "./features/batchExport/types";
|
||||
// annotation
|
||||
export * from "./features/annotation/types";
|
||||
|
||||
// scores
|
||||
export * from "./features/scores/scoreConfigTypes";
|
||||
export * from "./features/scores/scoreTypes";
|
||||
|
||||
// comments
|
||||
export * from "./features/comments/types";
|
||||
|
||||
// export db types only
|
||||
export * from "@prisma/client";
|
||||
export { type DB } from "../prisma/generated/types";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import { CloudConfigRateLimit } from "./rate-limits";
|
||||
|
||||
export const CloudConfigSchema = z.object({
|
||||
plan: z.enum(["Hobby", "Pro", "Team", "Enterprise"]).optional(),
|
||||
monthlyObservationLimit: z.number().int().positive().optional(),
|
||||
// used for table and dashboard queries
|
||||
defaultLookBackDays: z.number().int().positive().optional(),
|
||||
// need to update stripe webhook if you change this, it fetches from db via these fields
|
||||
stripe: z
|
||||
.object({
|
||||
customerId: z.string().optional(),
|
||||
activeSubscriptionId: z.string().optional(),
|
||||
activeProductId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// custom rate limits for an organization
|
||||
rateLimitOverrides: CloudConfigRateLimit.optional(),
|
||||
});
|
||||
|
||||
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type Organization } from "@prisma/client";
|
||||
import { CloudConfigSchema } from "./cloudConfigSchema";
|
||||
|
||||
type parsedOrg = Omit<Organization, "cloudConfig"> & {
|
||||
cloudConfig: CloudConfigSchema | null;
|
||||
};
|
||||
|
||||
export function parseDbOrg(dbOrg: Organization): parsedOrg {
|
||||
const { cloudConfig, ...org } = dbOrg;
|
||||
|
||||
const parsedCloudConfig = CloudConfigSchema.safeParse(cloudConfig);
|
||||
|
||||
const parsedOrg = {
|
||||
...org,
|
||||
cloudConfig: parsedCloudConfig.success ? parsedCloudConfig.data : null,
|
||||
};
|
||||
|
||||
return parsedOrg;
|
||||
}
|
||||
@@ -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",
|
||||
@@ -45,6 +53,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "timeToFirstToken",
|
||||
type: "number",
|
||||
internal: 'o."completion_start_time" - o."start_time"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Latency (s)",
|
||||
@@ -57,24 +66,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 +101,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "statusMessage",
|
||||
type: "string",
|
||||
internal: 'o."status_message"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Model",
|
||||
@@ -95,24 +109,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 +155,7 @@ export const observationsTableCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 'o."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Prompt Name",
|
||||
@@ -144,12 +163,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 +189,7 @@ export type ObservationOptions = {
|
||||
traceName: Array<OptionsDefinition>;
|
||||
scores_avg: Array<string>;
|
||||
promptName: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function observationsTableColsWithOptions(
|
||||
@@ -182,6 +211,9 @@ export function observationsTableColsWithOptions(
|
||||
if (col.id === "promptName") {
|
||||
return { ...col, options: options?.promptName ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function orderByToPrismaSql(
|
||||
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(
|
||||
@@ -40,6 +40,6 @@ export function orderByToPrismaSql(
|
||||
|
||||
// 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") : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
+18
-18
@@ -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);
|
||||
@@ -29,22 +34,13 @@ export const getSessionTableSQL = (
|
||||
);
|
||||
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}
|
||||
`;
|
||||
@@ -1 +1 @@
|
||||
export { getSessionTableSQL } from "./getSessionTableSQL";
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
| {
|
||||
validKey: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type AuthHeaderValidVerificationResult = {
|
||||
validKey: true;
|
||||
scope: ApiAccessScope;
|
||||
};
|
||||
|
||||
export type ApiAccessScope = {
|
||||
projectId: string;
|
||||
accessLevel: "all" | "scores";
|
||||
orgId: string;
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
};
|
||||
@@ -1,18 +1,27 @@
|
||||
export * from "./services/S3StorageService";
|
||||
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
|
||||
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth";
|
||||
export * from "./auth/auth";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse";
|
||||
export * from "../server/definitions";
|
||||
export * from "../server/ingestion/IngestionUtils";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/model-match";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/validateAndInflateScore";
|
||||
export * from "./redis/redis";
|
||||
export * from "./redis/trace-upsert";
|
||||
export * from "./clickhouse";
|
||||
export * from "./redis/batch-export";
|
||||
export * from "./redis/ingestionFlushQueue";
|
||||
export * from "./redis/legacy-ingestion";
|
||||
export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
export * from "./instrumentation";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { eventTypes, IngestionEventType } from "./types";
|
||||
|
||||
const reservedCharsEscapeMap = [
|
||||
{ reserved: ":", escape: "|%|" },
|
||||
{ reserved: "_", escape: "|#|" },
|
||||
];
|
||||
|
||||
export enum ClickhouseEntityType {
|
||||
Trace = "trace",
|
||||
Score = "score",
|
||||
Observation = "observation",
|
||||
SDK_LOG = "sdk-log",
|
||||
}
|
||||
|
||||
export class IngestionUtils {
|
||||
public static getBufferKey(flushKey: string): string {
|
||||
const { projectId, eventType, entityId } =
|
||||
IngestionUtils.parseFlushKey(flushKey);
|
||||
|
||||
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
|
||||
|
||||
return (
|
||||
"ingestionBuffer:" + `${projectId}_${eventType}_${sanitizedEntityId}`
|
||||
);
|
||||
}
|
||||
|
||||
public static getFlushKey(params: {
|
||||
projectId: string;
|
||||
eventType: ClickhouseEntityType;
|
||||
entityId: string;
|
||||
batchTimestamp: string;
|
||||
}): string {
|
||||
const { projectId, eventType, entityId, batchTimestamp } = params;
|
||||
const sanitizedEntityId = IngestionUtils.escapeReservedChars(entityId);
|
||||
|
||||
return `${projectId}_${eventType}_${sanitizedEntityId}_${batchTimestamp}`;
|
||||
}
|
||||
|
||||
public static parseFlushKey(projectEntityKey: string) {
|
||||
const split = projectEntityKey.split("_");
|
||||
|
||||
if (split.length < 3) {
|
||||
throw new Error(
|
||||
`Invalid project entity key format ${projectEntityKey}, expected 3 or 4 parts`
|
||||
);
|
||||
}
|
||||
|
||||
const [projectId, eventType, escapedEntityId, batchTimestamp] = split;
|
||||
const entityId = IngestionUtils.unescapeReservedChars(escapedEntityId);
|
||||
|
||||
return { projectId, eventType, entityId, batchTimestamp };
|
||||
}
|
||||
|
||||
private static escapeReservedChars(string: string): string {
|
||||
return reservedCharsEscapeMap.reduce(
|
||||
(acc, { reserved, escape }) => acc.replaceAll(reserved, escape),
|
||||
string
|
||||
);
|
||||
}
|
||||
|
||||
private static unescapeReservedChars(escapedString: string): string {
|
||||
return reservedCharsEscapeMap.reduce(
|
||||
(acc, { reserved, escape }) => acc.replaceAll(escape, reserved),
|
||||
escapedString
|
||||
);
|
||||
}
|
||||
|
||||
public static getEventType(event: IngestionEventType): ClickhouseEntityType {
|
||||
switch (event.type) {
|
||||
case eventTypes.TRACE_CREATE:
|
||||
return ClickhouseEntityType.Trace;
|
||||
case eventTypes.OBSERVATION_CREATE:
|
||||
case eventTypes.OBSERVATION_UPDATE:
|
||||
case eventTypes.EVENT_CREATE:
|
||||
case eventTypes.SPAN_CREATE:
|
||||
case eventTypes.SPAN_UPDATE:
|
||||
case eventTypes.GENERATION_CREATE:
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
return ClickhouseEntityType.Observation;
|
||||
case eventTypes.SCORE_CREATE:
|
||||
return ClickhouseEntityType.Score;
|
||||
case eventTypes.SDK_LOG:
|
||||
return ClickhouseEntityType.SDK_LOG;
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
-222
@@ -1,56 +1,53 @@
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
import { type ApiAccessScope } from "@/src/features/public-api/server/types";
|
||||
import { LangfuseNotFoundError, InvalidRequestError } from "@langfuse/shared";
|
||||
import {
|
||||
type legacyObservationCreateEvent,
|
||||
eventTypes,
|
||||
type scoreEvent,
|
||||
type generationCreateEvent,
|
||||
type sdkLogEvent,
|
||||
type traceEvent,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { ScoreDataType, prisma } from "@langfuse/shared/src/db";
|
||||
import { type ObservationEvent, findModel } from "@langfuse/shared/src/server";
|
||||
import { ResourceNotFoundError } from "@/src/utils/exceptions";
|
||||
import { mergeJson } from "@langfuse/shared";
|
||||
import {
|
||||
type Trace,
|
||||
type Observation,
|
||||
type Score,
|
||||
type Prisma,
|
||||
type Model,
|
||||
} from "@langfuse/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
import { type z } from "zod";
|
||||
import { jsonSchema } from "@langfuse/shared";
|
||||
import { ForbiddenError } from "@langfuse/shared";
|
||||
import { instrument } from "@/src/utils/instrumentation";
|
||||
import Decimal from "decimal.js";
|
||||
import { findModel } from "../model-match";
|
||||
import {
|
||||
ScoreBodyWithoutConfig,
|
||||
ScorePropsAgainstConfig,
|
||||
} from "@/src/features/public-api/types/scores";
|
||||
import {
|
||||
validateDbScoreConfigSafe,
|
||||
type ValidatedScoreConfig,
|
||||
} from "@/src/features/public-api/types/score-configs";
|
||||
ObservationEvent,
|
||||
eventTypes,
|
||||
legacyObservationCreateEvent,
|
||||
generationCreateEvent,
|
||||
traceEvent,
|
||||
scoreEvent,
|
||||
sdkLogEvent,
|
||||
} from "../types";
|
||||
import { validateAndInflateScore } from "../validateAndInflateScore";
|
||||
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 ".";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
export class ObservationProcessor implements EventProcessor {
|
||||
event: ObservationEvent;
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined;
|
||||
|
||||
constructor(event: ObservationEvent) {
|
||||
constructor(
|
||||
event: ObservationEvent,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
) {
|
||||
this.event = event;
|
||||
this.calculateTokenDelegate = calculateTokenDelegate;
|
||||
}
|
||||
|
||||
async convertToObservation(
|
||||
apiScope: ApiAccessScope,
|
||||
existingObservation: Observation | null,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
existingObservation: Observation | null
|
||||
): Promise<{
|
||||
id: string;
|
||||
create: Prisma.ObservationUncheckedCreateInput;
|
||||
@@ -79,7 +76,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
this.event.type === eventTypes.OBSERVATION_UPDATE &&
|
||||
!existingObservation
|
||||
) {
|
||||
throw new ResourceNotFoundError(this.event.id, "Observation not found");
|
||||
throw new LangfuseNotFoundError(
|
||||
`Observation with id ${this.event.id} not found`
|
||||
);
|
||||
}
|
||||
|
||||
// find matching model definition based on event and existing observation in db
|
||||
@@ -122,8 +121,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
"usage" in this.event.body
|
||||
? this.calculateTokenCounts(
|
||||
this.event.body,
|
||||
this.calculateTokenDelegate,
|
||||
internalModel ?? undefined,
|
||||
existingObservation ?? undefined,
|
||||
existingObservation ?? undefined
|
||||
)
|
||||
: [undefined, undefined];
|
||||
|
||||
@@ -159,7 +159,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
|
||||
internalModel,
|
||||
userProvidedTokenCosts,
|
||||
tokenCounts,
|
||||
tokenCounts
|
||||
);
|
||||
|
||||
// merge metadata from existingObservation.metadata and metadata
|
||||
@@ -167,7 +167,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation?.metadata
|
||||
? jsonSchema.parse(existingObservation.metadata)
|
||||
: undefined,
|
||||
this.event.body.metadata ?? undefined,
|
||||
this.event.body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const prompt =
|
||||
@@ -313,34 +313,34 @@ export class ObservationProcessor implements EventProcessor {
|
||||
body:
|
||||
| z.infer<typeof legacyObservationCreateEvent>["body"]
|
||||
| z.infer<typeof generationCreateEvent>["body"],
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
model?: Model,
|
||||
existingObservation?: Observation,
|
||||
existingObservation?: Observation
|
||||
) {
|
||||
return instrument({ name: "calculate-tokens" }, () => {
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
((body.input || existingObservation?.input) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? tokenCount({
|
||||
model: model,
|
||||
text: body.input ?? existingObservation?.input,
|
||||
})
|
||||
: undefined);
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
((body.input || existingObservation?.input) && model && model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.input ?? existingObservation?.input,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
const newCompletionTokens =
|
||||
body.usage?.output ??
|
||||
((body.output || existingObservation?.output) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? tokenCount({
|
||||
model: model,
|
||||
text: body.output ?? existingObservation?.output,
|
||||
})
|
||||
: undefined);
|
||||
const newCompletionTokens =
|
||||
body.usage?.output ??
|
||||
((body.output || existingObservation?.output) &&
|
||||
model &&
|
||||
model.tokenizerId
|
||||
? calculateTokenDelegate({
|
||||
model: model,
|
||||
text: body.output ?? existingObservation?.output,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
return [newPromptTokens, newCompletionTokens];
|
||||
});
|
||||
return [newPromptTokens, newCompletionTokens];
|
||||
}
|
||||
|
||||
static calculateTokenCosts(
|
||||
@@ -350,7 +350,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
outputCost?: Decimal | null;
|
||||
totalCost?: Decimal | null;
|
||||
},
|
||||
tokenCounts: { input?: number; output?: number; total?: number },
|
||||
tokenCounts: { input?: number; output?: number; total?: number }
|
||||
): {
|
||||
inputCost?: Decimal | null;
|
||||
outputCost?: Decimal | null;
|
||||
@@ -367,7 +367,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
totalCost:
|
||||
userProvidedCosts.totalCost ??
|
||||
(userProvidedCosts.inputCost ?? new Decimal(0)).add(
|
||||
userProvidedCosts.outputCost ?? new Decimal(0),
|
||||
userProvidedCosts.outputCost ?? new Decimal(0)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -398,9 +398,13 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for observation creation");
|
||||
}
|
||||
|
||||
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
|
||||
this.auth(apiScope);
|
||||
|
||||
const existingObservation = this.event.body.id
|
||||
? await prisma.observation.findFirst({
|
||||
@@ -413,7 +417,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
existingObservation.projectId !== apiScope.projectId
|
||||
) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for observation creation ${existingObservation.projectId} `,
|
||||
`Access denied for observation creation ${existingObservation.projectId} `
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,15 +441,17 @@ export class TraceProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
this.auth(apiScope);
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
@@ -453,7 +459,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
"Trying to create trace, project ",
|
||||
apiScope.projectId,
|
||||
", id:",
|
||||
internalId,
|
||||
internalId
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
@@ -464,7 +470,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
if (existingTrace && existingTrace.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for trace creation ${existingTrace.projectId}`,
|
||||
`Access denied for trace creation ${existingTrace.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -472,7 +478,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
: undefined,
|
||||
body.metadata ?? undefined,
|
||||
body.metadata ?? undefined
|
||||
);
|
||||
|
||||
const mergedTags =
|
||||
@@ -547,154 +553,19 @@ export class ScoreProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
static inferDataType(value: string | number): ScoreDataType {
|
||||
return typeof value === "number"
|
||||
? ScoreDataType.NUMERIC
|
||||
: ScoreDataType.CATEGORICAL;
|
||||
}
|
||||
|
||||
static mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
static inflateScoreBody(
|
||||
body: any,
|
||||
id: string,
|
||||
projectId: string,
|
||||
config?: ValidatedScoreConfig,
|
||||
): Score {
|
||||
const relevantDataType = config?.dataType ?? body.dataType;
|
||||
const scoreProps = { ...body, id, projectId, source: "API" };
|
||||
|
||||
if (typeof body.value === "number") {
|
||||
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
stringValue: body.value === 1 ? "True" : "False",
|
||||
dataType: ScoreDataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...scoreProps,
|
||||
value: config
|
||||
? ScoreProcessor.mapStringValueToNumericValue(config, body.value)
|
||||
: null,
|
||||
stringValue: body.value,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
};
|
||||
}
|
||||
|
||||
validateConfigAgainstBody(body: any, 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}`,
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"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}`,
|
||||
);
|
||||
}
|
||||
|
||||
const relevantDataType = configDataType ?? body.dataType;
|
||||
|
||||
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: relevantDataType,
|
||||
});
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rangeValidation = ScorePropsAgainstConfig.safeParse({
|
||||
value: body.value,
|
||||
dataType: relevantDataType,
|
||||
...(maxValue !== null && maxValue !== undefined && { maxValue }),
|
||||
...(minValue !== null && minValue !== undefined && { minValue }),
|
||||
...(categories && { categories }),
|
||||
});
|
||||
if (!rangeValidation.success) {
|
||||
const errorDetails = rangeValidation.error.errors
|
||||
.map((error) => `${error.path.join(".")} - ${error.message}`)
|
||||
.join(", ");
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateAndInflate(
|
||||
body: any,
|
||||
id: string,
|
||||
projectId: string,
|
||||
): Promise<Score> {
|
||||
if (body.configId) {
|
||||
const config = await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
id: body.configId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project",
|
||||
);
|
||||
|
||||
this.validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
return ScoreProcessor.inflateScoreBody(
|
||||
body,
|
||||
id,
|
||||
projectId,
|
||||
config as ValidatedScoreConfig,
|
||||
);
|
||||
} else {
|
||||
const validation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: body.dataType ?? ScoreProcessor.inferDataType(body.value),
|
||||
});
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
return ScoreProcessor.inflateScoreBody(body, id, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
this.auth(apiScope);
|
||||
|
||||
const id = body.id ?? v4();
|
||||
|
||||
@@ -708,15 +579,15 @@ export class ScoreProcessor implements EventProcessor {
|
||||
});
|
||||
if (existingScore && existingScore.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation ${existingScore.projectId}`,
|
||||
`Access denied for score creation ${existingScore.projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
const validatedScore = await this.validateAndInflate(
|
||||
const validatedScore = await validateAndInflateScore({
|
||||
body,
|
||||
id,
|
||||
apiScope.projectId,
|
||||
);
|
||||
scoreId: id,
|
||||
projectId: apiScope.projectId,
|
||||
});
|
||||
|
||||
return await prisma.score.upsert({
|
||||
where: {
|
||||
@@ -742,6 +613,10 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
process() {
|
||||
try {
|
||||
console.log("SDK Log", this.event);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type Redis } from "ioredis";
|
||||
import { QueueJobs } from "../../queues";
|
||||
import {
|
||||
getIngestionFlushQueue,
|
||||
IngestionFlushQueue,
|
||||
} from "../../redis/ingestionFlushQueue";
|
||||
import { IngestionUtils } from "../IngestionUtils";
|
||||
import { IngestionEventType } from "../types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { env } from "../../../env";
|
||||
|
||||
export async function enqueueIngestionEvents(
|
||||
projectId: string,
|
||||
events: IngestionEventType[]
|
||||
) {
|
||||
const ingestionFlushQueue = getIngestionFlushQueue();
|
||||
|
||||
if (!ingestionFlushQueue) {
|
||||
throw Error("IngestionFlushQueue not initialized");
|
||||
}
|
||||
if (!redis) throw Error("Redis connection not available");
|
||||
|
||||
const queuedEventPromises: Promise<void>[] = [];
|
||||
const batchTimestamp = Date.now().toString();
|
||||
|
||||
// Use for loop as TS does not narrow redis type in map function
|
||||
for (const event of events) {
|
||||
queuedEventPromises.push(
|
||||
enqueueSingleIngestionEvent(
|
||||
projectId,
|
||||
event,
|
||||
redis,
|
||||
ingestionFlushQueue,
|
||||
batchTimestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(queuedEventPromises);
|
||||
}
|
||||
|
||||
async function enqueueSingleIngestionEvent(
|
||||
projectId: string,
|
||||
event: IngestionEventType,
|
||||
redis: Redis,
|
||||
ingestionFlushQueue: IngestionFlushQueue,
|
||||
batchTimestamp: string
|
||||
): Promise<void> {
|
||||
if (!("id" in event.body && event.body.id)) {
|
||||
console.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const flushKey = IngestionUtils.getFlushKey({
|
||||
entityId: event.body.id,
|
||||
eventType: IngestionUtils.getEventType(event),
|
||||
projectId,
|
||||
batchTimestamp,
|
||||
});
|
||||
const bufferKey = IngestionUtils.getBufferKey(flushKey);
|
||||
const serializedEventData = JSON.stringify({ ...event, projectId });
|
||||
|
||||
await redis.lpush(bufferKey, serializedEventData);
|
||||
await redis.expire(bufferKey, env.LANGFUSE_INGESTION_BUFFER_TTL_SECONDS);
|
||||
await ingestionFlushQueue.add(QueueJobs.FlushIngestionEntity, null, {
|
||||
jobId: flushKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { env } from "node:process";
|
||||
import z from "zod";
|
||||
import { ForbiddenError, UnauthorizedError } from "../../../errors";
|
||||
import { eventTypes, ingestionApiSchema, ingestionEvent } from "../types";
|
||||
import {
|
||||
EventProcessor,
|
||||
TraceProcessor,
|
||||
ObservationProcessor,
|
||||
ScoreProcessor,
|
||||
SdkLogProcessor,
|
||||
} from "./EventProcessor";
|
||||
import { EventBodyType, EventName, TraceUpsertEventType } from "../../queues";
|
||||
import {
|
||||
convertTraceUpsertEventsToRedisEvents,
|
||||
getTraceUpsertQueue,
|
||||
} from "../../redis/trace-upsert";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
|
||||
|
||||
export type BatchResult = {
|
||||
result: unknown;
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type TokenCountInput = {
|
||||
model: Model;
|
||||
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: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
const results: BatchResult[] = []; // Array to store the results
|
||||
|
||||
const errors: {
|
||||
error: unknown;
|
||||
id: string;
|
||||
type: string;
|
||||
}[] = []; // Array to store the errors
|
||||
|
||||
for (const singleEvent of events) {
|
||||
try {
|
||||
const result = await retry(async () => {
|
||||
return await handleSingleEvent(
|
||||
singleEvent,
|
||||
authCheck.scope,
|
||||
calculateTokenDelegate
|
||||
);
|
||||
});
|
||||
results.push({
|
||||
result: result,
|
||||
id: singleEvent.id,
|
||||
type: singleEvent.type,
|
||||
}); // Push each result into the array
|
||||
} catch (error) {
|
||||
// Handle or log the error if `handleSingleEvent` fails
|
||||
console.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({
|
||||
error: error,
|
||||
id: singleEvent.id,
|
||||
type: singleEvent.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (env.CLICKHOUSE_URL) {
|
||||
try {
|
||||
await enqueueIngestionEvents(authCheck.scope.projectId, events);
|
||||
console.log(`Added ${events.length} ingestion events to queue`);
|
||||
} catch (err) {
|
||||
console.error("Error adding ingestion events to queue", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { results, errors };
|
||||
};
|
||||
|
||||
async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
return await backOff(request, {
|
||||
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");
|
||||
return false;
|
||||
}
|
||||
console.log(`retrying processing events ${attemptNumber}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleSingleEvent = async (
|
||||
event: z.infer<typeof ingestionEvent>,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
) => {
|
||||
const { body } = event;
|
||||
let restEvent = body;
|
||||
if ("input" in body) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { input, ...rest } = body;
|
||||
restEvent = rest;
|
||||
}
|
||||
if ("output" in restEvent) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { output, ...rest } = restEvent;
|
||||
restEvent = rest;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
|
||||
);
|
||||
|
||||
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
|
||||
|
||||
const { type } = cleanedEvent;
|
||||
|
||||
let processor: EventProcessor;
|
||||
switch (type) {
|
||||
case eventTypes.TRACE_CREATE:
|
||||
processor = new TraceProcessor(cleanedEvent);
|
||||
break;
|
||||
case eventTypes.OBSERVATION_CREATE:
|
||||
case eventTypes.OBSERVATION_UPDATE:
|
||||
case eventTypes.EVENT_CREATE:
|
||||
case eventTypes.SPAN_CREATE:
|
||||
case eventTypes.SPAN_UPDATE:
|
||||
case eventTypes.GENERATION_CREATE:
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
processor = new ObservationProcessor(
|
||||
cleanedEvent,
|
||||
calculateTokenDelegate
|
||||
);
|
||||
break;
|
||||
case eventTypes.SCORE_CREATE: {
|
||||
processor = new ScoreProcessor(cleanedEvent);
|
||||
break;
|
||||
}
|
||||
case eventTypes.SDK_LOG:
|
||||
processor = new SdkLogProcessor(cleanedEvent);
|
||||
}
|
||||
|
||||
// Deny access to non-score events if the access level is not "all"
|
||||
// This is an additional safeguard to auth checks in EventProcessor
|
||||
if (apiScope.accessLevel !== "all" && type !== eventTypes.SCORE_CREATE) {
|
||||
throw new ForbiddenError("Access denied. Event type not allowed.");
|
||||
}
|
||||
|
||||
return await processor.process(apiScope);
|
||||
};
|
||||
|
||||
// cleans NULL characters from the event
|
||||
export function cleanEvent(obj: unknown): unknown {
|
||||
if (typeof obj === "string") {
|
||||
return obj.replace(/\u0000/g, "");
|
||||
} else if (typeof obj === "object" && obj !== null) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(cleanEvent);
|
||||
} else {
|
||||
// Here we assert that obj is a Record<string, unknown>
|
||||
const objAsRecord = obj as Record<string, unknown>;
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in objAsRecord) {
|
||||
newObj[key] = cleanEvent(objAsRecord[key]);
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
export const isNotNullOrUndefined = <T>(
|
||||
val?: T | null
|
||||
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
|
||||
|
||||
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
val === undefined || val === null;
|
||||
|
||||
export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
batchResults: BatchResult[],
|
||||
projectId: string
|
||||
): Promise<void> => {
|
||||
const traceEvents: TraceUpsertEventType[] = batchResults
|
||||
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
|
||||
.map((result) =>
|
||||
result.result &&
|
||||
typeof result.result === "object" &&
|
||||
"id" in result.result
|
||||
? // ingestion API only gets traces for one projectId
|
||||
{ traceId: result.result.id as string, projectId }
|
||||
: null
|
||||
)
|
||||
.filter(isNotNullOrUndefined);
|
||||
|
||||
try {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
|
||||
const queue = getTraceUpsertQueue();
|
||||
if (!queue) {
|
||||
console.error("TraceUpsertQueue not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
await queue.addBulk(convertTraceUpsertEventsToRedisEvents(traceEvents));
|
||||
} else if (
|
||||
env.LANGFUSE_WORKER_HOST &&
|
||||
env.LANGFUSE_WORKER_PASSWORD &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
const body: EventBodyType = {
|
||||
name: EventName.TraceUpsert,
|
||||
payload: traceEvents,
|
||||
};
|
||||
|
||||
if (traceEvents.length > 0) {
|
||||
await fetch(`${env.LANGFUSE_WORKER_HOST}/api/events`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization:
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
|
||||
).toString("base64"),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(8 * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending events to worker", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
ScoreBodyWithoutConfig,
|
||||
ScorePropsAgainstConfig,
|
||||
validateDbScoreConfigSafe,
|
||||
ValidatedScoreConfig,
|
||||
} from "../../../src";
|
||||
import { prisma, Score, ScoreDataType } from "../../db";
|
||||
|
||||
import { InvalidRequestError, LangfuseNotFoundError } from "../../errors";
|
||||
|
||||
type ValidateAndInflateScoreParams = {
|
||||
projectId: string;
|
||||
scoreId: string;
|
||||
body: any;
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams
|
||||
): Promise<Score> {
|
||||
const { body, projectId } = params;
|
||||
|
||||
if (body.configId) {
|
||||
const config = await prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
id: body.configId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project"
|
||||
);
|
||||
|
||||
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
|
||||
return inflateScoreBody({
|
||||
...params,
|
||||
config: config as ValidatedScoreConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: body.dataType ?? inferDataType(body.value),
|
||||
});
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
return inflateScoreBody(params);
|
||||
}
|
||||
|
||||
function inferDataType(value: string | number): ScoreDataType {
|
||||
return typeof value === "number"
|
||||
? ScoreDataType.NUMERIC
|
||||
: ScoreDataType.CATEGORICAL;
|
||||
}
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
|
||||
): Score {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
const relevantDataType = config?.dataType ?? body.dataType;
|
||||
const scoreProps = { ...body, id: scoreId, projectId, source: "API" };
|
||||
|
||||
if (typeof body.value === "number") {
|
||||
if (relevantDataType && relevantDataType === ScoreDataType.BOOLEAN) {
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
stringValue: body.value === 1 ? "True" : "False",
|
||||
dataType: ScoreDataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: body.value,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...scoreProps,
|
||||
value: config ? mapStringValueToNumericValue(config, body.value) : null,
|
||||
stringValue: body.value,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"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}`
|
||||
);
|
||||
}
|
||||
|
||||
const relevantDataType = configDataType ?? body.dataType;
|
||||
|
||||
const dataTypeValidation = ScoreBodyWithoutConfig.safeParse({
|
||||
...body,
|
||||
dataType: relevantDataType,
|
||||
});
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`
|
||||
);
|
||||
}
|
||||
|
||||
const rangeValidation = ScorePropsAgainstConfig.safeParse({
|
||||
value: body.value,
|
||||
dataType: relevantDataType,
|
||||
...(maxValue !== null && maxValue !== undefined && { maxValue }),
|
||||
...(minValue !== null && minValue !== undefined && { minValue }),
|
||||
...(categories && { categories }),
|
||||
});
|
||||
|
||||
if (!rangeValidation.success) {
|
||||
const errorDetails = rangeValidation.error.errors
|
||||
.map((error) => `${error.path.join(".")} - ${error.message}`)
|
||||
.join(", ");
|
||||
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Instrumentation at Langfuse
|
||||
|
||||
Throughout our applications we want to use as much Otel as possible. This helps us to be flexible choosing our observability backend, and we will benefit from features and packages built by the Otel community.
|
||||
|
||||
## How to use Otel in your application
|
||||
|
||||
- Use the `instrument` or `instrument` functions to wrap your functions with Otel instrumentation. This will automatically create spans for your functions and send them to the Otel collector. If an instrumented function throws, exceptions will be added to the span and the span will be marked as failed.
|
||||
- Use `recordGauge`, `recordCounter`, `recordHistogram` to record metrics. These will be sent to the Otel collector.
|
||||
|
||||
## Configuration options
|
||||
|
||||
- `web` and `worker` have an instrumentation.ts file, which configures otel for the application.
|
||||
- For trpc, we use `@baselime/trpc-opentelemetry-middleware` to enrich spans with trpc inputs and outputs.
|
||||
- When building adding new infrastructure, we should search for auto instumentations for our code base.
|
||||
|
||||
## Library support
|
||||
|
||||
- `dd-trace` has a direct dependency of `@opentelemetry/api` version < `1.9` [GH](https://github.com/DataDog/dd-trace-js/blob/ed9b0b30f7b0283579a9bf8c18e1f9deab18fecf/package.json#L81).
|
||||
- `@prisma/instrumentation` requires at least `@opentelemetry/api` version `1.8` [GH](https://github.com/prisma/prisma/blob/d780290b13754420abcfa5d7592f02049c6cc005/packages/instrumentation/package.json#L25)
|
||||
- We need to ensure that all other libraries we use are compatible with `@opentelemetry/api` version `1.8`. If we have a package using a later version, this will break the instrumentation and crash the container. We can check the versions with the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter=web why @opentelemetry/api
|
||||
```
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import * as dd from "dd-trace";
|
||||
|
||||
// type CallbackFn<T> = () => T;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type AsyncCallbackFn<T> = () => Promise<T>;
|
||||
|
||||
export async function instrumentAsync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: AsyncCallbackFn<T>
|
||||
): Promise<T> {
|
||||
return await getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await callback();
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type SyncCallbackFn<T> = () => T;
|
||||
|
||||
export function instrumentSync<T>(
|
||||
ctx: SpanCtx,
|
||||
callback: SyncCallbackFn<T>
|
||||
): T {
|
||||
return getTracer(ctx.traceScope ?? callback.name).startActiveSpan(
|
||||
ctx.name,
|
||||
{
|
||||
root: ctx.rootSpan,
|
||||
kind: ctx.spanKind,
|
||||
},
|
||||
(span) => {
|
||||
try {
|
||||
const result = callback();
|
||||
span.end();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
traceException(ex as opentelemetry.Exception, span);
|
||||
span.end();
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const getCurrentSpan = () => opentelemetry.trace.getActiveSpan();
|
||||
|
||||
export const traceException = (
|
||||
ex: unknown,
|
||||
span?: opentelemetry.Span,
|
||||
code?: string
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exception = {
|
||||
code: code,
|
||||
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
|
||||
activeSpan.recordException(exception);
|
||||
|
||||
//adds tags for datadog error tracking
|
||||
activeSpan.setAttributes({
|
||||
"error.stack": exception.stack,
|
||||
"error.message": exception.message,
|
||||
"error.type": exception.name,
|
||||
});
|
||||
|
||||
activeSpan.setStatus({
|
||||
code: opentelemetry.SpanStatusCode.ERROR,
|
||||
message: exception.message,
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: { userId?: string; projectId?: string; email?: string },
|
||||
span?: opentelemetry.Span
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
attributes.userId && activeSpan.setAttribute("user.id", attributes.userId);
|
||||
attributes.email && activeSpan.setAttribute("user.email", attributes.email);
|
||||
attributes.projectId &&
|
||||
activeSpan.setAttribute("project.id", attributes.projectId);
|
||||
};
|
||||
|
||||
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
|
||||
|
||||
export const recordGauge = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?:
|
||||
| {
|
||||
[tag: string]: string | number;
|
||||
}
|
||||
| undefined
|
||||
) => {
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
|
||||
export const recordIncrement = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
) => {
|
||||
dd.dogstatsd.increment(stat, value, tags);
|
||||
};
|
||||
|
||||
export const recordHistogram = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined
|
||||
) => {
|
||||
dd.dogstatsd.histogram(stat, value, tags);
|
||||
};
|
||||
@@ -48,6 +48,7 @@ export const ZodModelConfig = z.object({
|
||||
// NOTE: Update docs page when changing this!
|
||||
export const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import { ingestionBatchEvent } from ".";
|
||||
|
||||
export enum EventName {
|
||||
TraceUpsert = "TraceUpsert",
|
||||
BatchExport = "BatchExport",
|
||||
EvaluationExecution = "EvaluationExecution",
|
||||
LegacyIngestion = "LegacyIngestion",
|
||||
CloudUsageMetering = "CloudUsageMetering",
|
||||
}
|
||||
|
||||
export const LegacyIngestionEvent = z.object({
|
||||
data: ingestionBatchEvent,
|
||||
authCheck: z.object({
|
||||
validKey: z.literal(true),
|
||||
scope: z.object({
|
||||
projectId: z.string(),
|
||||
accessLevel: z.enum(["all", "scores"]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const BatchExportJobSchema = z.object({
|
||||
projectId: z.string(),
|
||||
batchExportId: z.string(),
|
||||
@@ -22,6 +36,7 @@ export const EvalExecutionEvent = z.object({
|
||||
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
|
||||
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
|
||||
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
|
||||
export type LegacyIngestionEventType = z.infer<typeof LegacyIngestionEvent>;
|
||||
|
||||
export const EventBodySchema = z.union([
|
||||
z.object({
|
||||
@@ -45,6 +60,8 @@ export enum QueueName {
|
||||
BatchExport = "batch-export-queue",
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue",
|
||||
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -53,6 +70,8 @@ export enum QueueJobs {
|
||||
BatchExportJob = "batch-export-job",
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
CloudUsageMeteringJob = "cloud-usage-metering-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -74,4 +93,10 @@ export type TQueueJobTypes = {
|
||||
payload: BatchExportJobType;
|
||||
name: QueueJobs.BatchExportJob;
|
||||
};
|
||||
[QueueName.LegacyIngestionQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: LegacyIngestionEventType;
|
||||
name: QueueJobs.LegacyIngestionJob;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
null;
|
||||
|
||||
export const getBatchExportQueue = () => {
|
||||
if (batchExportQueue) return batchExportQueue;
|
||||
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
batchExportQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 2,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return batchExportQueue;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Queue } from "bullmq";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { createNewRedisInstance } from "../redis/redis";
|
||||
import { QueueName } from "../queues";
|
||||
|
||||
export type IngestionFlushQueue = Queue<null>;
|
||||
|
||||
let ingestionFlushQueue: IngestionFlushQueue | null = null;
|
||||
|
||||
export const getIngestionFlushQueue = () => {
|
||||
if (ingestionFlushQueue) return ingestionFlushQueue;
|
||||
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
ingestionFlushQueue = connection
|
||||
? new Queue<null>(QueueName.IngestionFlushQueue, {
|
||||
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,
|
||||
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
|
||||
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return ingestionFlushQueue;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
|
||||
const newRedis = createNewRedisInstance();
|
||||
|
||||
legacyIngestionQueue = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
@@ -1,18 +1,26 @@
|
||||
import Redis from "ioredis";
|
||||
import { env } from "../../env";
|
||||
|
||||
export const createNewRedisInstance = () => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
})
|
||||
: 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",
|
||||
})
|
||||
: 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");
|
||||
return null;
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
QueueName,
|
||||
TQueueJobTypes,
|
||||
TraceUpsertEventType,
|
||||
} from "../../queues";
|
||||
} 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,15 @@ 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,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -50,7 +56,7 @@ export function convertTraceUpsertEventsToRedisEvents(
|
||||
name: QueueJobs.TraceUpsert as const,
|
||||
},
|
||||
opts: {
|
||||
removeOnFail: 10000,
|
||||
removeOnFail: 1_000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class PromptService {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for Sentry metrics
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
) {
|
||||
@@ -18,12 +18,6 @@ export class PromptService {
|
||||
(cacheEnabled || env.LANGFUSE_CACHE_PROMPT_ENABLED === "true");
|
||||
|
||||
this.ttlSeconds = env.LANGFUSE_CACHE_PROMPT_TTL_SECONDS;
|
||||
|
||||
if (this.cacheEnabled) {
|
||||
this.logInfo("Prompt cache enabled with TTL seconds", this.ttlSeconds);
|
||||
} else {
|
||||
this.logInfo("Prompt cache disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public async getPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
|
||||
+10
-9
@@ -15,25 +15,25 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface ProjectInvitationTemplateProps {
|
||||
interface MembershipInvitationTemplateProps {
|
||||
invitedByUsername: string;
|
||||
invitedByUserEmail: string;
|
||||
projectName: string;
|
||||
orgName: string;
|
||||
receiverEmail: string;
|
||||
inviteLink: string;
|
||||
emailFromAddress: string;
|
||||
langfuseCloudRegion?: string;
|
||||
}
|
||||
|
||||
export const ProjectInvitationTemplate = ({
|
||||
export const MembershipInvitationTemplate = ({
|
||||
invitedByUsername,
|
||||
invitedByUserEmail,
|
||||
projectName,
|
||||
orgName,
|
||||
receiverEmail,
|
||||
inviteLink,
|
||||
emailFromAddress,
|
||||
langfuseCloudRegion,
|
||||
}: ProjectInvitationTemplateProps) => {
|
||||
}: MembershipInvitationTemplateProps) => {
|
||||
const previewText = `Join ${invitedByUsername} on Langfuse`;
|
||||
|
||||
return (
|
||||
@@ -53,7 +53,7 @@ export const ProjectInvitationTemplate = ({
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
|
||||
Join <strong>{projectName}</strong> on <strong>Langfuse</strong>
|
||||
Join <strong>{orgName}</strong> on <strong>Langfuse</strong>
|
||||
</Heading>
|
||||
<Text className="text-sm leading-6 text-black">Hello,</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
@@ -64,7 +64,8 @@ export const ProjectInvitationTemplate = ({
|
||||
>
|
||||
{invitedByUserEmail}
|
||||
</Link>
|
||||
) has invited you to the <strong>{projectName}</strong> project on
|
||||
) has invited you to join the <strong>{orgName}</strong>{" "}
|
||||
organization on
|
||||
{langfuseCloudRegion
|
||||
? ` Langfuse (${langfuseCloudRegion} data region)`
|
||||
: " Langfuse"}
|
||||
@@ -78,7 +79,7 @@ export const ProjectInvitationTemplate = ({
|
||||
Accept Invitation
|
||||
</Button>
|
||||
<Text className="mt-2 text-xs leading-3 text-muted-foreground">
|
||||
(you need to create an account)
|
||||
(you need to create an account with this email address)
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
@@ -102,4 +103,4 @@ export const ProjectInvitationTemplate = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectInvitationTemplate;
|
||||
export default MembershipInvitationTemplate;
|
||||
+11
-14
@@ -2,7 +2,7 @@ import { createTransport } from "nodemailer";
|
||||
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import ProjectInvitationTemplate from "./ProjectInvitationEmailTemplate";
|
||||
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
@@ -10,7 +10,7 @@ const langfuseUrls = {
|
||||
STAGING: "https://staging.langfuse.com",
|
||||
};
|
||||
|
||||
type SendProjectInvitationParams = {
|
||||
type SendMembershipInvitationParams = {
|
||||
env: Partial<
|
||||
Record<
|
||||
| "EMAIL_FROM_ADDRESS"
|
||||
@@ -23,19 +23,19 @@ type SendProjectInvitationParams = {
|
||||
to: string;
|
||||
inviterName: string;
|
||||
inviterEmail: string;
|
||||
projectName: string;
|
||||
orgName: string;
|
||||
};
|
||||
|
||||
export const sendProjectInvitationEmail = async ({
|
||||
export const sendMembershipInvitationEmail = async ({
|
||||
env,
|
||||
to,
|
||||
inviterName,
|
||||
inviterEmail,
|
||||
projectName,
|
||||
}: SendProjectInvitationParams) => {
|
||||
orgName,
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending project invitation email."
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -59,10 +59,10 @@ export const sendProjectInvitationEmail = async ({
|
||||
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
|
||||
|
||||
const htmlTemplate = render(
|
||||
ProjectInvitationTemplate({
|
||||
MembershipInvitationTemplate({
|
||||
invitedByUsername: inviterName,
|
||||
invitedByUserEmail: inviterEmail,
|
||||
projectName: projectName,
|
||||
orgName: orgName,
|
||||
receiverEmail: to,
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
@@ -72,11 +72,8 @@ export const sendProjectInvitationEmail = async ({
|
||||
|
||||
await mailer.sendMail({
|
||||
to,
|
||||
from: {
|
||||
address: env.EMAIL_FROM_ADDRESS,
|
||||
name: "Langfuse",
|
||||
},
|
||||
subject: `${inviterName} invited you to join "${projectName}"`,
|
||||
from: `Langfuse <${env.EMAIL_FROM_ADDRESS}>`,
|
||||
subject: `${inviterName} invited you to join "${orgName}" organization on Langfuse`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -63,23 +63,29 @@ export const parseJsonPrioritised = (
|
||||
): z.infer<typeof jsonSchema> | string | undefined => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(json);
|
||||
|
||||
if (Object.keys(parsedJson).length === 0) {
|
||||
return undefined;
|
||||
return parsedJson;
|
||||
}
|
||||
const arr = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (arr.success) {
|
||||
return arr.data;
|
||||
|
||||
const parsedArray = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedArray.success) {
|
||||
return parsedArray.data;
|
||||
}
|
||||
const obj = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (obj.success) {
|
||||
return obj.data;
|
||||
|
||||
const parsedObject = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (parsedObject.success) {
|
||||
return parsedObject.data;
|
||||
}
|
||||
|
||||
return jsonSchema.parse(parsedJson);
|
||||
} catch (error) {
|
||||
return jsonSchema.parse(json);
|
||||
const parsed = jsonSchema.safeParse(json);
|
||||
|
||||
return parsed.success ? parsed.data : json;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertRecordToJsonSchema = (
|
||||
record: Record<string, string>
|
||||
): JsonNested | undefined => {
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const isPresent = <T>(value: T | null | undefined): value is T =>
|
||||
value !== null && value !== undefined && value !== "";
|
||||
|
||||
export const stringDateTime = z.string().datetime({ offset: true }).nullish();
|
||||
@@ -4,7 +4,7 @@
|
||||
"jsx": "react",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "./dist",
|
||||
"types": ["node"],
|
||||
"target": "ES2020",
|
||||
|
||||
Generated
+4044
-1842
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -34,13 +34,15 @@ 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 LANFUSE_WEB_MIGRATION_DISABLED
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
@@ -100,4 +102,4 @@ ENV PORT 3000
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command
|
||||
CMD ["node", "./web/server.js"]
|
||||
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
|
||||
|
||||
+49
-22
@@ -12,26 +12,49 @@ import { env } from "./src/env.mjs";
|
||||
*/
|
||||
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;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://client.crisp.chat https://settings.crisp.chat https://challenges.cloudflare.com https://*.sentry.io https://ph.langfuse.com https://static.cloudflareinsights.com https://*.stripe.com;
|
||||
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://client.crisp.chat https://storage.crisp.chat wss://client.relay.crisp.chat wss://stream.relay.crisp.chat https://*.ingest.us.sentry.io https://ph.langfuse.com;
|
||||
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,
|
||||
|
||||
/**
|
||||
* If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config
|
||||
@@ -66,6 +89,7 @@ const nextConfig = {
|
||||
key: "Permissions-Policy",
|
||||
value: "autoplay=*, fullscreen=*, microphone=*",
|
||||
},
|
||||
...(env.SENTRY_CSP_REPORT_URI ? [reportToHeader] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -80,22 +104,22 @@ const nextConfig = {
|
||||
// Required to check authentication status from langfuse.com
|
||||
...(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined
|
||||
? [
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
source: "/api/auth/session",
|
||||
headers: [
|
||||
{
|
||||
key: "Access-Control-Allow-Origin",
|
||||
value: "https://langfuse.com",
|
||||
},
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST" },
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Content-Type, Authorization",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// all files in /public/generated are public and can be accessed from any origin, e.g. to render an API reference based on our openapi schema
|
||||
{
|
||||
@@ -115,7 +139,7 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
// webassembly support for @dqbd/tiktoken
|
||||
webpack(config) {
|
||||
webpack(config, { isServer }) {
|
||||
config.experiments = {
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
@@ -154,6 +178,9 @@ const sentryOptions = {
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
|
||||
+58
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.68.0",
|
||||
"version": "2.75.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14,14 +14,17 @@
|
||||
"prettier": "prettier --write ./src *.{ts,js}",
|
||||
"clean": "rm -rf node_modules",
|
||||
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathIgnorePatterns='__e2e__'",
|
||||
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env -- playwright test",
|
||||
"test:e2e:server": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --testPathPattern='__e2e__'",
|
||||
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
|
||||
"generationCost:backfill": "dotenv -e ../.env -- tsx scripts/observations-backfill-calculated-cost.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@appsignal/opentelemetry-instrumentation-bullmq": "^0.7.1",
|
||||
"@baselime/trpc-opentelemetry-middleware": "^0.1.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lint": "^6.8.0",
|
||||
"@headlessui/react": "1.7.18",
|
||||
@@ -36,33 +39,46 @@
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
"@mui/x-tree-view": "^7.6.2",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@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",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.25.1",
|
||||
"@opentelemetry/core": "^1.14.0",
|
||||
"@opentelemetry/instrumentation": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-metrics": "1.23.0",
|
||||
"@opentelemetry/sdk-node": "^0.50.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@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/profiling-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",
|
||||
@@ -72,51 +88,54 @@
|
||||
"@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",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.5",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"graphql": "^16.9.0",
|
||||
"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.429.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"posthog-js": "^1.122.0",
|
||||
"posthog-js": "^1.154.6",
|
||||
"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",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"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",
|
||||
@@ -146,18 +165,19 @@
|
||||
"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",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"typescript": "^5.4.5"
|
||||
"tsx": "^4.18.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.13.0"
|
||||
|
||||
@@ -1242,7 +1242,7 @@ paths:
|
||||
schema: {}
|
||||
security: *ref_0
|
||||
post:
|
||||
description: Create a prompt
|
||||
description: Create a new version for the prompt with the given `name`
|
||||
operationId: prompts_create
|
||||
tags:
|
||||
- Prompts
|
||||
|
||||
@@ -937,7 +937,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Create",
|
||||
"request": {
|
||||
"description": "Create a prompt",
|
||||
"description": "Create a new version for the prompt with the given `name`",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts",
|
||||
"host": [
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 140"><g fill-rule="nonzero" fill="none"><path d="M55.383 75.225c-1.874 3.748-7.222 3.748-9.096 0l-4.482-8.963a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.963Zm0 50.836c-1.874 3.747-7.222 3.747-9.096 0l-4.482-8.964a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.964Z" fill="#1D4AFF"/><path d="M0 106.765c0-4.53 5.477-6.8 8.68-3.596l23.307 23.307c3.204 3.204.935 8.68-3.595 8.68H5.085A5.085 5.085 0 0 1 0 130.073v-23.307Zm0-24.55c0 1.35.536 2.643 1.49 3.596l47.856 47.856a5.086 5.086 0 0 0 3.595 1.49h26.286c4.53 0 6.799-5.477 3.595-8.681L8.681 52.334C5.477 49.131 0 51.4 0 55.93v26.286Zm0-50.834c0 1.348.536 2.642 1.49 3.595l98.69 98.691a5.086 5.086 0 0 0 3.596 1.49h26.285c4.53 0 6.8-5.477 3.596-8.681L8.681 1.5C5.477-1.704 0 .565 0 5.095v26.286Zm50.835 0c0 1.348.536 2.642 1.49 3.595l91.5 91.5c3.203 3.204 8.68.935 8.68-3.596V96.595a5.086 5.086 0 0 0-1.49-3.596l-91.5-91.5c-3.203-3.203-8.68-.934-8.68 3.596v26.286ZM110.35 1.5c-3.203-3.204-8.68-.935-8.68 3.595v26.286c0 1.348.536 2.642 1.49 3.595l40.664 40.665c3.204 3.204 8.68.935 8.68-3.596V45.76a5.086 5.086 0 0 0-1.489-3.596L110.35 1.5Z" fill="#F9BD2B"/><path d="m216.24 107.388-47.864-47.863c-3.204-3.204-8.681-.935-8.681 3.595v66.952a5.085 5.085 0 0 0 5.085 5.085h74.142a5.085 5.085 0 0 0 5.085-5.085v-6.097c0-2.809-2.286-5.052-5.07-5.414a39.27 39.27 0 0 1-22.698-11.173Zm-32.145 11.502a8.137 8.137 0 0 1-8.133-8.134 8.137 8.137 0 0 1 8.133-8.134 8.137 8.137 0 0 1 8.134 8.134 8.137 8.137 0 0 1-8.134 8.134Z" fill="#000"/><path d="M0 130.072a5.085 5.085 0 0 0 5.085 5.085h23.307c4.53 0 6.799-5.477 3.595-8.681L8.681 103.169C5.477 99.966 0 102.235 0 106.765v23.307Zm50.835-86.418L8.68 1.5C5.477-1.704 0 .565 0 5.095v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V43.654ZM8.68 52.334C5.477 49.131 0 51.4 0 55.93v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V94.489L8.68 52.334Z" fill="#1D4AFF"/><path d="M101.67 45.76a5.083 5.083 0 0 0-1.49-3.596L59.516 1.5c-3.204-3.204-8.681-.935-8.681 3.595v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V45.76Zm-50.835 89.397h28.392c4.53 0 6.799-5.477 3.595-8.681L50.835 94.489v40.668Zm0-91.503v38.562c0 1.348.536 2.642 1.49 3.595l49.345 49.346V96.595a5.084 5.084 0 0 0-1.49-3.596L50.835 43.654Z" fill="#F54E00"/><path d="M303.32 114.86h20.888V80.22h17.452c19.17 0 31.466-11.37 31.466-28.954 0-17.584-12.295-28.954-31.466-28.954h-38.34v92.547Zm20.888-52.488V40.16h15.337c7.932 0 12.692 4.23 12.692 11.105 0 6.876-4.76 11.106-12.692 11.106h-15.337Zm86.71 53.545c20.36 0 35.167-14.543 35.167-34.375 0-19.831-14.807-34.374-35.167-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375Zm-15.866-34.375c0-10.577 6.346-17.848 15.866-17.848 9.386 0 15.733 7.271 15.733 17.848 0 10.577-6.347 17.849-15.733 17.849-9.52 0-15.866-7.272-15.866-17.849Zm84.462 34.375c15.601 0 26.178-9.784 26.178-21.286 0-26.97-35.829-18.245-35.829-28.822 0-2.908 3.04-4.759 7.404-4.759 4.495 0 9.916 2.776 11.634 8.858l15.601-6.479c-3.04-9.65-14.279-16.261-27.896-16.261-14.676 0-23.798 8.725-23.798 19.17 0 25.252 35.3 18.245 35.3 28.69 0 3.702-3.437 6.214-8.594 6.214-7.403 0-12.56-5.156-14.146-11.37l-15.601 6.081c3.438 10.048 13.486 19.964 29.747 19.964Zm76.43-1.718-1.321-16.791c-2.248 1.19-5.157 1.586-7.536 1.586-4.76 0-7.933-3.437-7.933-9.387V64.355h16.13v-16.13h-16.13V28.923h-19.435v19.302h-10.577v16.13h10.577v27.764c0 16.13 10.974 23.798 25.384 23.798 3.967 0 7.669-.66 10.842-1.718Zm67.764-91.887v35.961h-36.755v-35.96h-20.89v92.546h20.89V76.122h36.755v38.737h21.021V22.312h-21.021Zm67.386 93.605c20.36 0 35.168-14.543 35.168-34.375 0-19.831-14.807-34.374-35.168-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375ZM675.23 81.542c0-10.577 6.346-17.848 15.865-17.848 9.387 0 15.733 7.271 15.733 17.848 0 10.577-6.346 17.849-15.733 17.849-9.519 0-15.865-7.272-15.865-17.849Zm88.545 31.202c7.272 0 13.75-2.512 17.188-6.875v6.346c0 7.404-5.95 12.56-15.072 12.56-6.479 0-12.164-3.173-13.09-8.594l-17.715 2.777c2.38 12.56 15.204 21.022 30.805 21.022 20.492 0 34.11-12.032 34.11-29.88V48.225h-19.17v5.685c-3.57-4.098-9.652-6.742-17.452-6.742-18.51 0-30.144 12.692-30.144 32.788 0 20.096 11.634 32.788 30.54 32.788ZM752.14 79.956c0-9.916 5.817-16.262 14.807-16.262 9.123 0 14.94 6.346 14.94 16.262s-5.817 16.262-14.94 16.262c-8.99 0-14.807-6.346-14.807-16.262Z" fill="#000"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -8,8 +8,8 @@ import "dotenv/config";
|
||||
import { findModel } from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import lodash from "lodash";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
|
||||
async function main() {
|
||||
return await modelMatch();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// Replay may only be enabled for the client-side
|
||||
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_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
|
||||
// Capture Replay for 100% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
? Number(process.env.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE)
|
||||
: 0.5,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
debug: false,
|
||||
|
||||
beforeSend(event, _hint) {
|
||||
// Check if it is an exception, and if so, show the report dialog
|
||||
if (event.exception) {
|
||||
Sentry.showReportDialog({ eventId: event.event_id });
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
||||
// ...
|
||||
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString("base64");
|
||||
return `Basic ${auth}`;
|
||||
};
|
||||
|
||||
const workerAdminAuth = generateAuth("admin", "myworkerpassword");
|
||||
|
||||
const userApiKeyAuth = generateAuth("pk-lf-1234567890", "sk-lf-1234567890");
|
||||
|
||||
describe("Health endpoints", () => {
|
||||
it("web container returns healthy", async () => {
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/health";
|
||||
|
||||
// Act
|
||||
const response = await fetch(url);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("worker container returns healthy", async () => {
|
||||
// Arrange
|
||||
const url = "http://localhost:3030/api/health";
|
||||
|
||||
// Act
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: workerAdminAuth,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
await waitForExpect(async () => {
|
||||
// we need a second call to the public API with the API key, so that it is stored in redis
|
||||
// first call (ingestion above) generates the new, fast API hash
|
||||
// second call (below) stores the API key in redis
|
||||
const traceUrl = `http://localhost:3000/api/public/traces/${traceId}`;
|
||||
|
||||
const traceResponse = await fetch(traceUrl, {
|
||||
headers: {
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
});
|
||||
|
||||
expect(traceResponse.status).toBe(200);
|
||||
expect(traceResponse.body).not.toBeNull();
|
||||
expect((await traceResponse.json()).id).toBe(traceId);
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: {
|
||||
id: traceId,
|
||||
},
|
||||
});
|
||||
expect(trace).not.toBeNull();
|
||||
expect(trace?.name).toBe("test trace");
|
||||
|
||||
const observation = await prisma.observation.findUnique({
|
||||
where: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
},
|
||||
});
|
||||
expect(observation).not.toBeNull();
|
||||
expect(observation?.name).toBe("test span");
|
||||
|
||||
expect(redis).not.toBeNull();
|
||||
|
||||
const redisKeys = await redis?.keys(`api-key:*`);
|
||||
expect(redisKeys?.length).toBe(1);
|
||||
const redisValue = await redis?.get(redisKeys![0]);
|
||||
|
||||
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
|
||||
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
// check for eval
|
||||
await waitForExpect(async () => {
|
||||
const evalExecution = await prisma.jobExecution.findFirst({
|
||||
where: {
|
||||
jobInputTraceId: traceId,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
},
|
||||
});
|
||||
|
||||
expect(evalExecution).not.toBeNull();
|
||||
|
||||
if (!evalExecution) {
|
||||
return;
|
||||
}
|
||||
|
||||
// failure due to missing openai key in the pipeline. Expected
|
||||
expect(evalExecution.status).toBe(JobExecutionStatus.ERROR);
|
||||
}, 20000);
|
||||
|
||||
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", () => {
|
||||
it("creates and returns a prompt", async () => {
|
||||
const promptName = "prompt-name" + v4();
|
||||
const chatMessages = [
|
||||
{ role: "system", content: "You are a bot" },
|
||||
{ role: "user", content: "What's up?" },
|
||||
];
|
||||
const response = await fetch(
|
||||
"http://localhost:3000/api/public/v2/prompts",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: promptName,
|
||||
prompt: chatMessages,
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const fetchedPrompt = await fetch(
|
||||
`http://localhost:3000/api/public/v2/prompts/${encodeURIComponent(promptName)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(fetchedPrompt.status).toBe(200);
|
||||
expect(fetchedPrompt.body).not.toBeNull();
|
||||
|
||||
if (fetchedPrompt.body === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedPrompt = await fetchedPrompt.json();
|
||||
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
|
||||
const redisKey = `prompt:7a88fb47-b4e2-43b8-a06c-a5ce950dc53a:${promptName}:${validatedPrompt.labels[0]}`;
|
||||
const redisValue = await redis?.get(redisKey);
|
||||
|
||||
expect(redisValue).not.toBeNull();
|
||||
if (!redisValue) {
|
||||
return;
|
||||
}
|
||||
expect(JSON.parse(redisValue)).toEqual(validatedPrompt);
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,7 @@ test("should redirect to home if signed in", async ({ page }) => {
|
||||
// wait 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
if (process.env.CI)
|
||||
await expect(page).toHaveURL(
|
||||
// project id from seed.ts
|
||||
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
else
|
||||
console.log(
|
||||
"Test skipped as redirect depends on db state, URL after signing in:",
|
||||
page.url(),
|
||||
);
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Successfully sign up & able to go to homepage", async ({ page }) => {
|
||||
@@ -35,7 +26,7 @@ test("Successfully sign up & able to go to homepage", async ({ page }) => {
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/?getStarted=1");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Successfully sign up & able to go to homepage with uppercase email", async ({
|
||||
@@ -48,7 +39,7 @@ test("Successfully sign up & able to go to homepage with uppercase email", async
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// see get started page
|
||||
await expect(page).toHaveURL("/?getStarted=1");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Signup input validation", async ({ page }) => {
|
||||
@@ -64,7 +55,7 @@ test("Signup input validation", async ({ page }) => {
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
// don't see get started page
|
||||
await expect(page).not.toHaveURL("/?getStarted=1");
|
||||
await expect(page).not.toHaveURL("/");
|
||||
});
|
||||
|
||||
// random email address to be used in tests
|
||||
@@ -83,7 +74,8 @@ test("Unauthenticated user should be redirected to target URL after login", asyn
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// project id and prompt from seed.ts
|
||||
const promptUrl = "/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
|
||||
const promptUrl =
|
||||
"/project/7a88fb47-b4e2-43b8-a06c-a5ce950dc53a/prompts/summary-prompt";
|
||||
|
||||
await page.getByRole("button", { name: /Demo User/ }).click();
|
||||
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("should see new projects dialog open after clicking new project btn", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("auth/sign-up");
|
||||
await page.fill('input[name="name"]', "demo user");
|
||||
await page.fill('input[name="email"]', randomEmailAddress());
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-up-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.innerHTML("data-testid=create-new-project-title")).toBe(
|
||||
"Create new project",
|
||||
);
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create a project with provided name", async ({ page }) => {
|
||||
test("Sign in, create an organization, create a project", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Sign in
|
||||
await page.goto("/auth/sign-in");
|
||||
await page.fill('input[name="email"]', "demo@langfuse.com");
|
||||
await page.fill('input[type="password"]', "password");
|
||||
await page.click('button[data-testid="submit-email-password-sign-in-form"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.isVisible('[data-testid="new-project-form"]');
|
||||
await page.click('[data-testid="create-project-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill(
|
||||
'[data-testid="new-project-name-input"]',
|
||||
"my e2e demo project",
|
||||
);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.getByTestId("project-name").textContent()).toContain(
|
||||
"my e2e demo project",
|
||||
);
|
||||
});
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// random email address to be used in tests
|
||||
const randomEmailAddress = () =>
|
||||
Math.random().toString(36).substring(2, 11) + "@example.com";
|
||||
// Start create org flow
|
||||
await page.isVisible('[data-testid="create-organization-btn"]');
|
||||
await page.click('[data-testid="create-organization-btn"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page).toHaveURL("/setup");
|
||||
|
||||
// Create an organization
|
||||
await expect(page.locator("data-testid=new-org-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-org-name-input"]', "e2e test org");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/organization/");
|
||||
expect(page.url()).toContain("/setup?orgstep=invite-members");
|
||||
|
||||
// Parse the organization ID from the URL using a simpler method
|
||||
const url = new URL(page.url());
|
||||
const organizationId = url.pathname.split("/")[2];
|
||||
console.log("organization", organizationId);
|
||||
|
||||
// Skip add new members step
|
||||
await page.isVisible('[data-testid="btn-skip-add-members"]');
|
||||
await page.click('[data-testid="btn-skip-add-members"]');
|
||||
expect(page.url()).toContain(
|
||||
"/organization/" + organizationId + "/setup?orgstep=create-project",
|
||||
);
|
||||
|
||||
// Create project
|
||||
await expect(page.locator("data-testid=new-project-form")).toBeVisible();
|
||||
await page.fill('[data-testid="new-project-name-input"]', "e2e test project");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(page.url()).toContain("/project/");
|
||||
expect(page.url()).toContain("/setup");
|
||||
|
||||
const projectUrl = new URL(page.url());
|
||||
const projectId = projectUrl.pathname.split("/")[2];
|
||||
|
||||
// check that the project exists by navigating to its dashboard
|
||||
await page.goto("/project/" + projectId);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/project/" + projectId);
|
||||
expect(page.url()).not.toContain("/setup");
|
||||
|
||||
const headings = await page.locator("h2").allTextContents();
|
||||
expect(headings).toContain("Dashboard");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { type APIScore } from "@langfuse/shared";
|
||||
|
||||
describe("aggregateScores", () => {
|
||||
it("should return an empty object for an empty array", () => {
|
||||
const scores: APIScore[] = [];
|
||||
expect(aggregateScores(scores)).toEqual({});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single numeric score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple numeric scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5, 7],
|
||||
average: 6,
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with different keys", () => {
|
||||
const scores: APIScore[] = [
|
||||
{
|
||||
name: "test1",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test1",
|
||||
source: "ANNOTATION",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test1-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test1-ANNOTATION-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [7],
|
||||
average: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single Categorical score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good"],
|
||||
valueCounts: [{ value: "good", count: 1 }],
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple Categorical scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "True",
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "False",
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-BOOLEAN": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["True", "False"],
|
||||
valueCounts: [
|
||||
{ value: "True", count: 1 },
|
||||
{ value: "False", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with mixed types and the same name", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "another comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "bad",
|
||||
comment: "last comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "last comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good", "bad", "good"],
|
||||
valueCounts: [
|
||||
{ value: "good", count: 2 },
|
||||
{ value: "bad", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -848,6 +848,12 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
const otherProject = await prisma.project.create({
|
||||
data: {
|
||||
name: "other-project",
|
||||
organization: {
|
||||
connectOrCreate: {
|
||||
where: { id: "other-org" },
|
||||
create: { name: "other-org", id: "other-org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -71,12 +71,19 @@ describe("observations.export RPC", () => {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "clgb17vnp000008jjere5g15i",
|
||||
name: "John Doe",
|
||||
projects: [
|
||||
name: "Demo User",
|
||||
organizations: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { v4 } from "uuid";
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { cleanEvent } from "@/src/pages/api/public/ingestion";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { cleanEvent } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/ingestion API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -707,19 +707,6 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbScore?.traceId).toBe(traceId);
|
||||
expect(dbScore?.observationId).toBe(generationId);
|
||||
expect(dbScore?.value).toBe(100.5);
|
||||
|
||||
const logEvent = await prisma.events.findFirst({
|
||||
where: {
|
||||
data: {
|
||||
path: ["body", "log"],
|
||||
string_contains: "ERROR",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(logEvent).toBeDefined();
|
||||
expect(logEvent).not.toBeFalsy();
|
||||
expect(JSON.stringify(logEvent?.data)).toContain("KeyError: 'model_name'");
|
||||
});
|
||||
|
||||
it("should upsert threats", async () => {
|
||||
@@ -1140,11 +1127,16 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
it("should not override a trace from a different project", async () => {
|
||||
const traceId = v4();
|
||||
const newProjectId = v4();
|
||||
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: newProjectId,
|
||||
name: "another-project",
|
||||
orgId: "other-org",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,19 +18,26 @@ describe("llmApiKey.all RPC", () => {
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
projects: [
|
||||
organizations: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: projectId,
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
templateFlag: true,
|
||||
evals: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
|
||||
@@ -773,19 +773,26 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
|
||||
const otherProjectId = "239ad00f-562f-411d-af14-831c75ddd875";
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: { orgId: "other-org", userId: "user-test" },
|
||||
},
|
||||
create: { userId: "user-test", orgId: "other-org", role: "OWNER" },
|
||||
update: { role: "OWNER" },
|
||||
});
|
||||
await prisma.project.upsert({
|
||||
where: { id: otherProjectId },
|
||||
create: {
|
||||
id: otherProjectId,
|
||||
name: "demo-app",
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: "user-test",
|
||||
},
|
||||
},
|
||||
orgId: "other-org",
|
||||
},
|
||||
update: {},
|
||||
update: { name: "demo-app", orgId: "other-org" },
|
||||
});
|
||||
|
||||
await createPromptInDB({
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("Build valid SQL queries", () => {
|
||||
table: "traces_scores",
|
||||
values: ["project-id"],
|
||||
strings: [
|
||||
` FROM traces t JOIN scores s ON t.id = s.trace_id AND s.data_type != 'CATEGORICAL' AND t.project_id = s.project_id WHERE t."project_id" = `,
|
||||
` FROM traces t JOIN scores s ON t.id = s.trace_id AND t.project_id = s.project_id WHERE t."project_id" = `,
|
||||
";",
|
||||
],
|
||||
} as const,
|
||||
@@ -182,13 +182,19 @@ describe("Build valid SQL queries", () => {
|
||||
|
||||
describe("should retrieve data", () => {
|
||||
it("should get a simple trace", async () => {
|
||||
await prisma.organization.upsert({
|
||||
where: { id: "other-org" },
|
||||
create: { id: "other-org", name: "other-org" },
|
||||
update: {},
|
||||
});
|
||||
await prisma.project.upsert({
|
||||
where: { id: "different-project-id" },
|
||||
create: {
|
||||
id: "different-project-id",
|
||||
name: "test-project",
|
||||
orgId: "other-org",
|
||||
},
|
||||
update: {},
|
||||
update: { name: "test-project", orgId: "other-org" },
|
||||
});
|
||||
|
||||
await prisma.trace.createMany({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GetScoreConfigResponse,
|
||||
PostScoreConfigResponse,
|
||||
GetScoreConfigsResponse,
|
||||
} from "@/src/features/public-api/types/score-configs";
|
||||
} from "@langfuse/shared";
|
||||
|
||||
const configOne = [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DeleteScoreResponse,
|
||||
GetScoreResponse,
|
||||
GetScoresResponse,
|
||||
} from "@/src/features/public-api/types/scores";
|
||||
} from "@langfuse/shared";
|
||||
import { z } from "zod";
|
||||
import { PostTracesV1Response } from "@/src/features/public-api/types/traces";
|
||||
|
||||
|
||||
@@ -14,12 +14,18 @@ describe("Traces TRPC Router", () => {
|
||||
user: {
|
||||
id: "clgb17vnp000008jjere5g15i",
|
||||
name: "John Doe",
|
||||
projects: [
|
||||
organizations: [
|
||||
{
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
role: "ADMIN",
|
||||
name: "test",
|
||||
cloudConfig: { defaultLookBackDays: null },
|
||||
id: "seed-org-id",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
projects: [
|
||||
{
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
role: "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
@@ -27,6 +33,7 @@ describe("Traces TRPC Router", () => {
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
|
||||
@@ -3,9 +3,9 @@ import Decimal from "decimal.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { ObservationProcessor } from "@/src/server/api/services/EventProcessor";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { ObservationProcessor } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("Token Cost Calculation", () => {
|
||||
const modelName = "gpt-test-" + nanoid();
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { PostGenerationsV1Response } from "@/src/features/public-api/types/generations";
|
||||
import { PostScoresResponse } from "@/src/features/public-api/types/scores";
|
||||
import {
|
||||
GetTraceV1Response,
|
||||
GetTracesV1Response,
|
||||
GetTraceV1Response,
|
||||
PostTracesV1Response,
|
||||
} from "@/src/features/public-api/types/traces";
|
||||
import { PostScoresResponse } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/traces API Endpoint", () => {
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
@@ -33,7 +34,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const trace = await makeZodVerifiedAPICall(
|
||||
GetTraceV1Response,
|
||||
@@ -64,7 +65,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
version: "2.0.0",
|
||||
public: true,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const dbTrace1 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
@@ -94,7 +95,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
version: "5.0.0",
|
||||
public: false,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const dbTrace2 = await prisma.trace.findFirst({
|
||||
where: {
|
||||
@@ -125,7 +126,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
id: "trace-1",
|
||||
tags: ["tag-1", "tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
@@ -135,7 +136,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
id: "trace-2",
|
||||
tags: ["tag-1"],
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
@@ -145,7 +146,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
id: "trace-3",
|
||||
tags: ["tag-2", "tag-3"],
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// multiple tags
|
||||
const traces = await makeZodVerifiedAPICall(
|
||||
@@ -206,7 +207,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
},
|
||||
);
|
||||
);
|
||||
console.log(traceId);
|
||||
|
||||
// Simulate observations with costs and latencies
|
||||
@@ -223,7 +224,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
startTime: "2021-01-01T00:00:00.000Z",
|
||||
endTime: "2021-01-01T00:10:00.000Z",
|
||||
},
|
||||
);
|
||||
);
|
||||
await makeZodVerifiedAPICall(
|
||||
PostGenerationsV1Response,
|
||||
"POST",
|
||||
@@ -234,7 +235,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
startTime: "2021-01-01T00:10:00.000Z",
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// Simulate scores on the trace
|
||||
const scoreId1 = uuidv4();
|
||||
@@ -249,7 +250,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
traceId: traceId,
|
||||
comment: "First score",
|
||||
},
|
||||
);
|
||||
);
|
||||
const scoreId2 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
@@ -262,7 +263,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
traceId: traceId,
|
||||
comment: "Second score",
|
||||
},
|
||||
);
|
||||
);
|
||||
const scoreId3 = uuidv4();
|
||||
await makeZodVerifiedAPICall(
|
||||
PostScoresResponse,
|
||||
@@ -275,7 +276,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
traceId: traceId,
|
||||
comment: "Third score",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// GET traces
|
||||
// Retrieve the trace with totalCost and latency
|
||||
@@ -334,7 +335,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostTracesV1Response,
|
||||
@@ -350,7 +351,7 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
// Filter by session ID
|
||||
const tracesBySessionId = await makeZodVerifiedAPICall(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
|
||||
describe("Token Count Functions", () => {
|
||||
const generateModel = (model: string, tokenizer: string) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = stripeWebhookApiHandler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user