Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898cca56f9 | ||
|
|
83e052e769 | ||
|
|
b5f19771fa | ||
|
|
644390183b | ||
|
|
4d8e9003fe | ||
|
|
651c46a270 | ||
|
|
6b127bced0 | ||
|
|
e3a837b561 | ||
|
|
11c04f02c8 | ||
|
|
536aa138bc | ||
|
|
61b596c46a | ||
|
|
ddf43c0836 | ||
|
|
d5ab03892a | ||
|
|
918b6643d2 | ||
|
|
937b4c0f0f | ||
|
|
9b7f046e71 | ||
|
|
fe63704508 | ||
|
|
c39279f677 | ||
|
|
4206f5025d | ||
|
|
0260a4e861 | ||
|
|
244adf0dc3 | ||
|
|
dc6b98d009 | ||
|
|
b20fc5a20b | ||
|
|
f59ad5fd60 | ||
|
|
06ef41e372 | ||
|
|
e215a2db99 | ||
|
|
5e247e88c9 | ||
|
|
8e1e27c868 | ||
|
|
4648a5be8b | ||
|
|
4677cb10a9 | ||
|
|
7ed03a0946 | ||
|
|
bbe40a0ba8 | ||
|
|
2cf9a75fa5 | ||
|
|
38acccbfac | ||
|
|
8327863176 | ||
|
|
6045324d2b | ||
|
|
87923e5d6c | ||
|
|
f584de7577 | ||
|
|
d01c403f62 | ||
|
|
a001e6072c | ||
|
|
d9a3f977bc | ||
|
|
8fae4f3051 | ||
|
|
c36b1ff51d | ||
|
|
d3cf604f4f | ||
|
|
cc886917b6 | ||
|
|
b924bf9e09 | ||
|
|
895825dd1c | ||
|
|
6ceccb649f | ||
|
|
911021ac5d | ||
|
|
f39778ef9f | ||
|
|
4d00e39c6b | ||
|
|
7af49fd649 | ||
|
|
662f1917b6 | ||
|
|
d8f047f2a1 | ||
|
|
69815a51b9 | ||
|
|
2bb419213a | ||
|
|
a330035c40 | ||
|
|
dd4434fe25 | ||
|
|
f898f57bff | ||
|
|
eda52c7b77 | ||
|
|
e5c7686bc7 | ||
|
|
251be1a240 | ||
|
|
66c810e4a7 | ||
|
|
c3c8a741e4 | ||
|
|
81c898b4ed | ||
|
|
834d0bcc7a | ||
|
|
40953d7732 | ||
|
|
d471be8783 | ||
|
|
0ca38d8070 | ||
|
|
12458e3d71 | ||
|
|
51575b5491 | ||
|
|
a9c11f3323 | ||
|
|
066140c903 | ||
|
|
0b7de157ad | ||
|
|
7dcc718c77 | ||
|
|
e098618614 | ||
|
|
3e8ba54aa0 | ||
|
|
ea36cd58a4 | ||
|
|
c7b726a73a | ||
|
|
112e7a2dcf | ||
|
|
c1d98aba6a | ||
|
|
75fd1cbd3f | ||
|
|
75ec734a45 | ||
|
|
98e951423c | ||
|
|
8e1dc3ee99 | ||
|
|
7668995ec9 | ||
|
|
06233f878c | ||
|
|
dae3d4e4e2 | ||
|
|
04dca2ad6b | ||
|
|
48c61ad77f | ||
|
|
34140302a6 | ||
|
|
93cc36ecdc | ||
|
|
ace30782a0 | ||
|
|
3c94a0cda9 | ||
|
|
b0fea44556 | ||
|
|
ed91330c37 | ||
|
|
b8ed9d681b | ||
|
|
ea2e1b4873 | ||
|
|
3ddef14e75 | ||
|
|
4658942701 | ||
|
|
51be8f8fb3 | ||
|
|
6ede3b7690 | ||
|
|
dee36bbe29 | ||
|
|
f71cf2f894 | ||
|
|
1a707fe147 | ||
|
|
8da1640777 | ||
|
|
1e56499f00 | ||
|
|
2524fb9648 | ||
|
|
91cd86a286 | ||
|
|
e6b9d23615 | ||
|
|
8b76feeffa | ||
|
|
88a53dc72d | ||
|
|
649a9af91c | ||
|
|
876917d112 | ||
|
|
bb6bc79bdc | ||
|
|
7aff2b2e61 | ||
|
|
8f742f660a | ||
|
|
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 |
@@ -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"
|
||||
}
|
||||
+26
-4
@@ -39,6 +39,10 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_DEFAULT_PROJECT_ID=
|
||||
# LANGFUSE_DEFAULT_PROJECT_ROLE=
|
||||
|
||||
# Logging, optional
|
||||
# LANGFUSE_LOG_LEVEL=info
|
||||
# LANGFUSE_LOG_FORMAT=text
|
||||
|
||||
# Enable experimental features, optional
|
||||
# LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
|
||||
|
||||
@@ -97,6 +101,17 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
# LANGFUSE_INIT_PROJECT_ID=project-id
|
||||
# LANGFUSE_INIT_PROJECT_NAME=project-name
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-1234567890
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-1234567890
|
||||
# LANGFUSE_INIT_USER_EMAIL=user@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
@@ -139,13 +154,12 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Sentry
|
||||
# NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# Used to determine the Sentry sample rate
|
||||
# LANGFUSE_TRACING_SAMPLE_RATE=
|
||||
# LANGFUSE_WORKER_BETTERSTACK_TOKEN=
|
||||
# SENTRY_CSP_REPORT_URI=
|
||||
|
||||
|
||||
# Cloudflare Turnstile
|
||||
@@ -167,6 +181,7 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
@@ -174,6 +189,13 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
|
||||
# Stripe
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
@@ -193,8 +215,8 @@ ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
|
||||
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
|
||||
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
+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/*"
|
||||
|
||||
@@ -40,6 +40,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and run both images from compose
|
||||
run: |
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
@@ -107,13 +110,26 @@ jobs:
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
|
||||
- name: Start Langfuse
|
||||
run: (pnpm run start&)
|
||||
env:
|
||||
LANGFUSE_INIT_ORG_ID: "seed-org-id"
|
||||
LANGFUSE_INIT_ORG_NAME: "Seed Org"
|
||||
LANGFUSE_INIT_PROJECT_ID: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "Seed Project"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-1234567890"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-1234567890"
|
||||
LANGFUSE_INIT_USER_EMAIL: "demo@langfuse.com"
|
||||
LANGFUSE_INIT_USER_NAME: "Demo User"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=web run test
|
||||
@@ -136,6 +152,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9.5.0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -165,18 +182,26 @@ jobs:
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
docker compose ps
|
||||
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
if docker compose ps | grep "(unhealthy)"; then
|
||||
echo "One or more services are unhealthy"
|
||||
exit 1
|
||||
else
|
||||
echo "All services are healthy"
|
||||
fi
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run --filter=shared ch:up
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
pnpm run --filter=shared ch:up
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter=worker... run build
|
||||
|
||||
- name: run tests
|
||||
run: pnpm --filter=worker run test
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -308,6 +333,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4060 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4037 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
@@ -27,3 +27,4 @@ jobs:
|
||||
PORTER_STACK_NAME: web
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4054 }}
|
||||
PORTER_NEXT_PUBLIC_BUILD_ID: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Snyk Container
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "dependabot/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
<div align="center"><h1> Langfuse: Open Source LLM Engineering Platform</h4></div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.71.0",
|
||||
"version": "2.78.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -18,5 +18,6 @@ if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
else
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-migrations-table-engine=MergeTree"
|
||||
fi
|
||||
|
||||
# Execute the up command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" up
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"import": "./dist/src/server/index.js",
|
||||
"require": "./dist/src/server/index.js"
|
||||
},
|
||||
"./src/server/auth/apiKeys": {
|
||||
"import": "./dist/src/server/auth/apiKeys.js",
|
||||
"require": "./dist/src/server/auth/apiKeys.js"
|
||||
},
|
||||
"./encryption": {
|
||||
"import": "./dist/src/encryption/index.js",
|
||||
"require": "./dist/src/encryption/index.js"
|
||||
@@ -57,29 +61,32 @@
|
||||
"@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",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely": "^0.27.4",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next-auth": "^4.24.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.14.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
"zod-to-json-schema": "^3.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
@@ -92,7 +99,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",
|
||||
@@ -101,7 +108,7 @@
|
||||
"vitest": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.79",
|
||||
"react": "^18.0.0"
|
||||
"@types/react": "~18.2.79",
|
||||
"react": "~18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,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;
|
||||
@@ -116,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;
|
||||
@@ -491,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -129,6 +129,7 @@ model Project {
|
||||
Score Score[]
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
comment Comment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
@@ -635,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")
|
||||
|
||||
+168
-80
@@ -12,7 +12,7 @@ import { parseArgs } from "node:util";
|
||||
import { chunk } from "lodash";
|
||||
import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../src/server";
|
||||
import { encrypt } from "../src/encryption";
|
||||
import { redis } from "../src/server/redis/redis";
|
||||
|
||||
@@ -257,22 +257,30 @@ 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,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Seeding ${traces.length} traces, ${observations.length} observations, and ${scores.length} scores`
|
||||
await uploadObjects(
|
||||
traces,
|
||||
observations,
|
||||
scores,
|
||||
sessions,
|
||||
events,
|
||||
comments,
|
||||
);
|
||||
|
||||
await uploadObjects(traces, observations, scores, sessions, events);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
|
||||
@@ -287,8 +295,8 @@ async function main() {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
|
||||
logger.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,7 +431,7 @@ async function main() {
|
||||
|
||||
for (const datasetItemId of datasetItemIds) {
|
||||
const relevantObservations = observations.filter(
|
||||
(o) => o.projectId === project2.id
|
||||
(o) => o.projectId === project2.id,
|
||||
);
|
||||
const observation =
|
||||
relevantObservations[
|
||||
@@ -449,13 +457,13 @@ main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
logger.error(e);
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
logger.info("Disconnected from postgres and redis");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -464,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>[] = [];
|
||||
|
||||
@@ -478,14 +487,14 @@ async function uploadObjects(
|
||||
},
|
||||
create: chunk[0]!,
|
||||
update: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Sessions ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -496,13 +505,13 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.trace.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Traces ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -512,14 +521,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Observations ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -529,14 +538,14 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.observation.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Events ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -546,13 +555,29 @@ async function uploadObjects(
|
||||
promises.push(
|
||||
prisma.score.createMany({
|
||||
data: chunk,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < promises.length; i++) {
|
||||
if (i + 1 >= promises.length || i % Math.ceil(promises.length / 10) === 0)
|
||||
console.log(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`
|
||||
logger.info(
|
||||
`Seeding of Scores ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
|
||||
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)
|
||||
logger.info(
|
||||
`Seeding of Comments ${((i + 1) / promises.length) * 100}% complete`,
|
||||
);
|
||||
await promises[i];
|
||||
}
|
||||
@@ -573,7 +598,7 @@ function createObjects(
|
||||
dataType: ScoreDataType;
|
||||
categories: ConfigCategory[] | null;
|
||||
}[]
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const traces: Prisma.TraceCreateManyInput[] = [];
|
||||
const observations: Prisma.ObservationCreateManyInput[] = [];
|
||||
@@ -581,12 +606,13 @@ 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
|
||||
// random date within last 90 days, with a linear bias towards more recent dates
|
||||
const traceTs = new Date(
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000)
|
||||
Date.now() - Math.floor(Math.random() ** 1.5 * 90 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
const envTag = envTags[Math.floor(Math.random() * envTags.length)];
|
||||
@@ -611,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
|
||||
@@ -669,6 +696,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: annotationScoreName,
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
projectId,
|
||||
authorUserId: `user-${i}`,
|
||||
@@ -685,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,
|
||||
@@ -697,6 +726,7 @@ function createObjects(
|
||||
traceId: trace.id,
|
||||
name: "Completeness",
|
||||
timestamp: traceTs,
|
||||
createdAt: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
@@ -707,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[] = [];
|
||||
@@ -714,17 +753,18 @@ function createObjects(
|
||||
for (let j = 0; j < Math.floor(Math.random() * 10) + 1; j++) {
|
||||
// add between 1 and 30 ms to trace timestamp
|
||||
const spanTsStart = new Date(
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30)
|
||||
traceTs.getTime() + Math.floor(Math.random() * 30),
|
||||
);
|
||||
// random duration of upto 5000ms
|
||||
const spanTsEnd = new Date(
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000)
|
||||
spanTsStart.getTime() + Math.floor(Math.random() * 5000),
|
||||
);
|
||||
|
||||
const span = {
|
||||
type: ObservationType.SPAN,
|
||||
id: `span-${v4()}`,
|
||||
startTime: spanTsStart,
|
||||
createdAt: spanTsStart,
|
||||
endTime: spanTsEnd,
|
||||
name: `span-${i}-${j}`,
|
||||
metadata: {
|
||||
@@ -752,22 +792,22 @@ function createObjects(
|
||||
const generationTsStart = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
const generationTsEnd = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() *
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime())
|
||||
)
|
||||
(spanTsEnd.getTime() - generationTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
// somewhere in the middle
|
||||
const generationTsCompletionStart = new Date(
|
||||
generationTsStart.getTime() +
|
||||
Math.floor(
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3
|
||||
)
|
||||
(generationTsEnd.getTime() - generationTsStart.getTime()) / 3,
|
||||
),
|
||||
);
|
||||
|
||||
const promptTokens = Math.floor(Math.random() * 1000) + 300;
|
||||
@@ -788,49 +828,25 @@ function createObjects(
|
||||
const promptId =
|
||||
promptIds.get(projectId)![
|
||||
Math.floor(
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2)
|
||||
Math.random() * Math.floor(promptIds.get(projectId)!.length / 2),
|
||||
)
|
||||
];
|
||||
|
||||
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: {
|
||||
@@ -867,6 +883,7 @@ function createObjects(
|
||||
source: ScoreSource.API,
|
||||
projectId: trace.projectId,
|
||||
timestamp: generationTsEnd,
|
||||
createdAt: traceTs,
|
||||
});
|
||||
if (Math.random() > 0.6)
|
||||
scores.push({
|
||||
@@ -877,6 +894,15 @@ function createObjects(
|
||||
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++) {
|
||||
@@ -884,14 +910,15 @@ function createObjects(
|
||||
const eventTs = new Date(
|
||||
spanTsStart.getTime() +
|
||||
Math.floor(
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime())
|
||||
)
|
||||
Math.random() * (spanTsEnd.getTime() - spanTsStart.getTime()),
|
||||
),
|
||||
);
|
||||
|
||||
events.push({
|
||||
type: ObservationType.EVENT,
|
||||
id: `event-${v4()}`,
|
||||
startTime: eventTs,
|
||||
createdAt: eventTs,
|
||||
name: `event-${i}-${j}-${k}-${l}`,
|
||||
metadata: {
|
||||
user: `user-${i}@langfuse.com`,
|
||||
@@ -906,7 +933,7 @@ function createObjects(
|
||||
}
|
||||
// find unique sessions by id and projectid
|
||||
const uniqueSessions: Prisma.TraceSessionCreateManyInput[] = Array.from(
|
||||
new Set(sessions.map((session) => JSON.stringify(session)))
|
||||
new Set(sessions.map((session) => JSON.stringify(session))),
|
||||
).map((session) => JSON.parse(session) as Prisma.TraceSessionCreateManyInput);
|
||||
|
||||
return {
|
||||
@@ -916,6 +943,7 @@ function createObjects(
|
||||
configs,
|
||||
sessions: uniqueSessions,
|
||||
events,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -926,7 +954,7 @@ async function generatePromptsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const promptIdsForProject = await generatePrompts(project);
|
||||
promptIds.set(project.id, promptIdsForProject);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return promptIds;
|
||||
}
|
||||
@@ -1112,7 +1140,7 @@ async function generateConfigsForProject(projects: Project[]) {
|
||||
projects.map(async (project) => {
|
||||
const configNameAndId = await generateConfigs(project);
|
||||
projectIdsToConfigs.set(project.id, configNameAndId);
|
||||
})
|
||||
}),
|
||||
);
|
||||
return projectIdsToConfigs;
|
||||
}
|
||||
@@ -1190,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 @@
|
||||
// This file exports the prisma db connection, the Prisma Object, and the Typescript types.
|
||||
// This is not imported in the index.ts file of this package, as we must not import this into FE code.
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { env } from "process";
|
||||
import kyselyExtension from "prisma-extension-kysely";
|
||||
import {
|
||||
@@ -11,17 +11,38 @@ import {
|
||||
PostgresQueryCompiler,
|
||||
} from "kysely";
|
||||
import { DB } from ".";
|
||||
import { logger } from "./server";
|
||||
|
||||
// Instantiated according to the Prisma documentation
|
||||
// https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient({
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error", "warn"],
|
||||
const client = new PrismaClient<
|
||||
Prisma.PrismaClientOptions,
|
||||
"warn" | "error" | "query"
|
||||
>({
|
||||
log: [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
{ emit: "event", level: "warn" },
|
||||
],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
client.$on("query", (event) => {
|
||||
logger.info(`prisma:query ${event.query}, ${event.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
client.$on("warn", (event) => {
|
||||
logger.warn(`prisma:warn ${event.message}`);
|
||||
});
|
||||
|
||||
client.$on("error", (event) => {
|
||||
logger.error(`prisma:error ${event.message}`);
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
@@ -38,7 +59,7 @@ const kyselySingleton = (prismaClient: PrismaClient) => {
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
declare global {
|
||||
|
||||
@@ -16,11 +16,12 @@ const EnvSchema = z.object({
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_ENABLE_AUTO_PIPELINING: z.enum(["true", "false"]).default("true"),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
64,
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
@@ -37,6 +38,11 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.positive()
|
||||
.default(60 * 10),
|
||||
SALT: z.string().optional(), // used by components imported by web package
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.optional(),
|
||||
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -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);
|
||||
@@ -144,7 +144,7 @@ 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
|
||||
*/
|
||||
|
||||
@@ -83,13 +83,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
z.object({
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -97,7 +97,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -155,13 +155,13 @@ 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[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): APIScore[] =>
|
||||
scores.reduce((acc, ts) => {
|
||||
const result = APIScoreSchema.safeParse(ts);
|
||||
@@ -198,14 +198,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
value: z.number(),
|
||||
dataType: z.literal("NUMERIC"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
@@ -215,14 +215,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
|
||||
}),
|
||||
dataType: z.literal("BOOLEAN"),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
dataType: z.undefined(),
|
||||
configId: z.string().nullish(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -256,7 +256,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
|
||||
trace: z.object({
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
export const GetScoresResponse = z.object({
|
||||
data: z.array(LegacyGetScoreResponseDataV1),
|
||||
@@ -265,7 +265,7 @@ export const GetScoresResponse = z.object({
|
||||
|
||||
export const legacyFilterAndValidateV1GetScoreList = (
|
||||
scores: unknown[],
|
||||
onParseError?: (error: z.ZodError) => void
|
||||
onParseError?: (error: z.ZodError) => void,
|
||||
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
|
||||
scores.reduce(
|
||||
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
|
||||
@@ -278,7 +278,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
|
||||
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
|
||||
);
|
||||
|
||||
// GET /scores/{scoreId}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
export * from "./constants";
|
||||
export * from "./queries";
|
||||
export * from "./interfaces/filters";
|
||||
export * from "./interfaces/orderBy";
|
||||
export * from "./interfaces/cloudConfigSchema";
|
||||
export * from "./interfaces/parseDbOrg";
|
||||
export * from "./tableDefinitions";
|
||||
export * from "./types";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth/auth";
|
||||
export * from "./server/auth/apiKeys";
|
||||
export * from "./observationsTable";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export * from "./utils/typeChecks";
|
||||
export * from "./features/entitlements/plans";
|
||||
export * from "./interfaces/rate-limits";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
@@ -30,6 +31,9 @@ export * from "./features/annotation/types";
|
||||
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>;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { CloudConfigSchema } from "@/src/features/organizations/utils/cloudConfigSchema";
|
||||
import { type Organization } from "@langfuse/shared";
|
||||
import { type Organization } from "@prisma/client";
|
||||
import { CloudConfigSchema } from "./cloudConfigSchema";
|
||||
|
||||
type parsedOrg = Omit<Organization, "cloudConfig"> & {
|
||||
cloudConfig: CloudConfigSchema | null;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { getSessionTableSQL } from "./getSessionTableSQL";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { env } from "../../env";
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
async function generateKeySet() {
|
||||
return {
|
||||
pk: `pk-lf-${randomUUID()}`,
|
||||
sk: `sk-lf-${randomUUID()}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function createAndAddApiKeysToDb(p: {
|
||||
prisma: PrismaClient;
|
||||
projectId: string;
|
||||
note?: string;
|
||||
predefinedKeys?: {
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
};
|
||||
}) {
|
||||
const salt = env.SALT;
|
||||
if (!salt) {
|
||||
throw new Error("SALT is not set");
|
||||
}
|
||||
|
||||
const { pk, sk } = p.predefinedKeys
|
||||
? { pk: p.predefinedKeys.publicKey, sk: p.predefinedKeys.secretKey }
|
||||
: await generateKeySet();
|
||||
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
const hashFromProvidedKey = createShaHash(sk, salt);
|
||||
|
||||
const apiKey = await p.prisma.apiKey.create({
|
||||
data: {
|
||||
projectId: p.projectId,
|
||||
publicKey: pk,
|
||||
hashedSecretKey: hashedSk,
|
||||
displaySecretKey: displaySk,
|
||||
fastHashedSecretKey: hashFromProvidedKey,
|
||||
note: p.note,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiKey.id,
|
||||
createdAt: apiKey.createdAt,
|
||||
note: apiKey.note,
|
||||
publicKey: apiKey.publicKey,
|
||||
secretKey: sk,
|
||||
displaySecretKey: displaySk,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
export function generateSecretKey() {
|
||||
return `sk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function generatePublicKey() {
|
||||
return `pk-lf-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function getDisplaySecretKey(secretKey: string) {
|
||||
return secretKey.slice(0, 6) + "..." + secretKey.slice(-4);
|
||||
}
|
||||
|
||||
export async function hashSecretKey(key: string) {
|
||||
// legacy, uses bcrypt, transformed into hashed key upon first use
|
||||
const hashedKey = await hash(key, 11);
|
||||
return hashedKey;
|
||||
}
|
||||
|
||||
export async function generateKeySet() {
|
||||
const pk = generatePublicKey();
|
||||
const sk = generateSecretKey();
|
||||
const hashedSk = await hashSecretKey(sk);
|
||||
const displaySk = getDisplaySecretKey(sk);
|
||||
|
||||
return {
|
||||
pk,
|
||||
sk,
|
||||
hashedSk,
|
||||
displaySk,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySecretKey(key: string, hashedKey: string) {
|
||||
const isValid = await compare(key, hashedKey);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function createShaHash(privateKey: string, salt: string): string {
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(privateKey)
|
||||
.update(crypto.createHash("sha256").update(salt, "utf8").digest("hex"))
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
|
||||
|
||||
interface CustomSSOUser extends Record<string, any> {
|
||||
email: string;
|
||||
id: string;
|
||||
name: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export function CustomSSOProvider<P extends CustomSSOUser>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "custom",
|
||||
name: "CustomSSOProvider",
|
||||
type: "oauth",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: "openid email profile" } }, // overridden by options.authorization to be able to set custom scopes, deep merged with this default
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,30 @@
|
||||
import z from "zod";
|
||||
import { Plan, plans } from "../../features/entitlements/plans";
|
||||
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
|
||||
|
||||
export const OrgEnrichedApiKey = z.object({
|
||||
id: z.string(),
|
||||
note: z.string().nullable(),
|
||||
publicKey: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
createdAt: z.string().datetime().nullable(),
|
||||
lastUsedAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
projectId: z.string(),
|
||||
fastHashedSecretKey: z.string(),
|
||||
hashedSecretKey: z.string(),
|
||||
orgId: z.string(),
|
||||
plan: z.enum(plans as unknown as [string, ...string[]]),
|
||||
rateLimitOverrides: CloudConfigRateLimit.nullish(),
|
||||
});
|
||||
|
||||
export const API_KEY_NON_EXISTENT = "api-key-non-existent";
|
||||
|
||||
export const CachedApiKey = z.union([
|
||||
OrgEnrichedApiKey,
|
||||
z.literal(API_KEY_NON_EXISTENT),
|
||||
]);
|
||||
|
||||
export type AuthHeaderVerificationResult =
|
||||
| AuthHeaderValidVerificationResult
|
||||
| {
|
||||
@@ -13,4 +40,7 @@ export type AuthHeaderValidVerificationResult = {
|
||||
export type ApiAccessScope = {
|
||||
projectId: string;
|
||||
accessLevel: "all" | "scores";
|
||||
orgId: string;
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
};
|
||||
|
||||
+34
-16
@@ -1,7 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ColumnDefinition, type TableNames } from "./tableDefinitions";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators } from "./interfaces/filters";
|
||||
import { ColumnDefinition, type TableNames } from "../tableDefinitions";
|
||||
import { FilterState } from "../types";
|
||||
import { filterOperators, timeFilter } from "../interfaces/filters";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./index";
|
||||
|
||||
const operatorReplacements = {
|
||||
"any of": "IN",
|
||||
@@ -24,7 +26,7 @@ const arrayOperatorReplacements = {
|
||||
export function tableColumnsToSqlFilterAndPrefix(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
): Prisma.Sql {
|
||||
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
|
||||
if (sql === Prisma.empty) {
|
||||
@@ -40,17 +42,17 @@ export function tableColumnsToSqlFilterAndPrefix(
|
||||
export function tableColumnsToSqlFilter(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
): Prisma.Sql {
|
||||
const internalFilters = filters.map((filter) => {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
(c) =>
|
||||
// TODO: Only use id instead of name
|
||||
c.name === filter.column || c.id === filter.column
|
||||
c.name === filter.column || c.id === filter.column,
|
||||
);
|
||||
if (!col) {
|
||||
console.error("Invalid filter column", filter.column);
|
||||
logger.error("Invalid filter column", filter.column);
|
||||
throw new Error("Invalid filter column: " + filter.column);
|
||||
}
|
||||
const colPrisma = Prisma.raw(col.internal);
|
||||
@@ -69,13 +71,13 @@ export function tableColumnsToSqlFilter(
|
||||
? Prisma.raw(
|
||||
arrayOperatorReplacements[
|
||||
filter.operator as keyof typeof arrayOperatorReplacements
|
||||
]
|
||||
],
|
||||
)
|
||||
: filter.operator in operatorReplacements
|
||||
? Prisma.raw(
|
||||
operatorReplacements[
|
||||
filter.operator as keyof typeof operatorReplacements
|
||||
]
|
||||
],
|
||||
)
|
||||
: Prisma.raw(filter.operator); //checked by zod
|
||||
|
||||
@@ -95,13 +97,13 @@ export function tableColumnsToSqlFilter(
|
||||
break;
|
||||
case "stringOptions":
|
||||
valuePrisma = Prisma.sql`(${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`)
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
)})`;
|
||||
break;
|
||||
case "arrayOptions":
|
||||
valuePrisma = Prisma.sql`ARRAY[${Prisma.join(
|
||||
filter.value.map((v) => Prisma.sql`${v}`),
|
||||
", "
|
||||
", ",
|
||||
)}] `;
|
||||
break;
|
||||
|
||||
@@ -121,12 +123,12 @@ export function tableColumnsToSqlFilter(
|
||||
filter.type === "string" || filter.type === "stringObject"
|
||||
? [
|
||||
["contains", "does not contain", "ends with"].includes(
|
||||
filter.operator
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw("'%' || ")
|
||||
: Prisma.empty,
|
||||
["contains", "does not contain", "starts with"].includes(
|
||||
filter.operator
|
||||
filter.operator,
|
||||
)
|
||||
? Prisma.raw(" || '%'")
|
||||
: Prisma.empty,
|
||||
@@ -150,7 +152,7 @@ export function tableColumnsToSqlFilter(
|
||||
|
||||
const castValueToPostgresTypes = (
|
||||
column: ColumnDefinition,
|
||||
table: TableNames
|
||||
table: TableNames,
|
||||
) => {
|
||||
return column.name === "type" &&
|
||||
(table === "observations" ||
|
||||
@@ -166,7 +168,7 @@ const dateOperators = filterOperators["datetime"];
|
||||
export const datetimeFilterToPrismaSql = (
|
||||
safeColumn: string,
|
||||
operator: (typeof dateOperators)[number],
|
||||
value: Date
|
||||
value: Date,
|
||||
) => {
|
||||
if (!dateOperators.includes(operator)) {
|
||||
throw new Error("Invalid operator: " + operator);
|
||||
@@ -176,6 +178,22 @@ export const datetimeFilterToPrismaSql = (
|
||||
}
|
||||
|
||||
return Prisma.sql`AND ${Prisma.raw(safeColumn)} ${Prisma.raw(
|
||||
operator
|
||||
operator,
|
||||
)} ${value}::timestamp with time zone at time zone 'UTC'`;
|
||||
};
|
||||
|
||||
export const datetimeFilterToPrisma = (
|
||||
timestampFilter: z.infer<typeof timeFilter>,
|
||||
) => {
|
||||
const prismaTimestampFilter =
|
||||
timestampFilter.operator === ">="
|
||||
? { gte: timestampFilter.value }
|
||||
: timestampFilter.operator === ">"
|
||||
? { gt: timestampFilter.value }
|
||||
: timestampFilter.operator === "<="
|
||||
? { lte: timestampFilter.value }
|
||||
: timestampFilter.operator === "<"
|
||||
? { lt: timestampFilter.value }
|
||||
: {};
|
||||
return prismaTimestampFilter;
|
||||
};
|
||||
@@ -3,7 +3,8 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth/auth";
|
||||
export * from "./auth/apiKeys";
|
||||
export * from "./auth/customSsoProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
@@ -24,3 +25,8 @@ export * from "./auth/types";
|
||||
export * from "./ingestion/legacy/index";
|
||||
export * from "./queues";
|
||||
export * from "./ingestion/legacy/EventProcessor";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./instrumentation";
|
||||
export * from "./logger";
|
||||
export * from "./queries";
|
||||
|
||||
@@ -12,18 +12,19 @@ import {
|
||||
sdkLogEvent,
|
||||
} from "../types";
|
||||
import { validateAndInflateScore } from "../validateAndInflateScore";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { Trace, Observation, Score, Prisma, Model } from "@prisma/client";
|
||||
import { ForbiddenError, LangfuseNotFoundError } from "../../../errors";
|
||||
import { mergeJson } from "../../../utils/json";
|
||||
import { jsonSchema } from "../../../utils/zod";
|
||||
import { prisma } from "../../../db";
|
||||
import { LegacyIngestionAccessScope } from ".";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export interface EventProcessor {
|
||||
auth(apiScope: ApiAccessScope): void;
|
||||
auth(apiScope: LegacyIngestionAccessScope): void;
|
||||
|
||||
process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> | undefined;
|
||||
}
|
||||
|
||||
@@ -39,15 +40,15 @@ export class ObservationProcessor implements EventProcessor {
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
}) => 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;
|
||||
@@ -77,7 +78,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
!existingObservation
|
||||
) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`Observation with id ${this.event.id} not found`
|
||||
`Observation with id ${this.event.id} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +124,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
this.event.body,
|
||||
this.calculateTokenDelegate,
|
||||
internalModel ?? undefined,
|
||||
existingObservation ?? undefined
|
||||
existingObservation ?? undefined,
|
||||
)
|
||||
: [undefined, undefined];
|
||||
|
||||
@@ -159,7 +160,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
const calculatedCosts = ObservationProcessor.calculateTokenCosts(
|
||||
internalModel,
|
||||
userProvidedTokenCosts,
|
||||
tokenCounts
|
||||
tokenCounts,
|
||||
);
|
||||
|
||||
// merge metadata from existingObservation.metadata and metadata
|
||||
@@ -167,7 +168,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 =
|
||||
@@ -187,8 +188,9 @@ export class ObservationProcessor implements EventProcessor {
|
||||
: undefined;
|
||||
|
||||
// Only null if promptName and promptVersion are set but prompt is not found
|
||||
if (prompt === null)
|
||||
console.warn("Prompt not found for observation", this.event.body);
|
||||
if (prompt === null) {
|
||||
logger.warn("Prompt not found for observation", this.event.body);
|
||||
}
|
||||
|
||||
const observationId = this.event.body.id ?? v4();
|
||||
|
||||
@@ -318,7 +320,7 @@ export class ObservationProcessor implements EventProcessor {
|
||||
text: unknown;
|
||||
}) => number | undefined,
|
||||
model?: Model,
|
||||
existingObservation?: Observation
|
||||
existingObservation?: Observation,
|
||||
) {
|
||||
const newPromptTokens =
|
||||
body.usage?.input ??
|
||||
@@ -350,7 +352,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 +369,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,12 +400,12 @@ export class ObservationProcessor implements EventProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for observation creation");
|
||||
}
|
||||
|
||||
async process(apiScope: ApiAccessScope): Promise<Observation> {
|
||||
async process(apiScope: LegacyIngestionAccessScope): Promise<Observation> {
|
||||
this.auth(apiScope);
|
||||
|
||||
const existingObservation = this.event.body.id
|
||||
@@ -417,7 +419,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} `,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -441,13 +443,13 @@ export class TraceProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope): void {
|
||||
auth(apiScope: LegacyIngestionAccessScope): void {
|
||||
if (apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError("Access denied for trace creation");
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -455,11 +457,8 @@ export class TraceProcessor implements EventProcessor {
|
||||
|
||||
const internalId = body.id ?? v4();
|
||||
|
||||
console.log(
|
||||
"Trying to create trace, project ",
|
||||
apiScope.projectId,
|
||||
", id:",
|
||||
internalId
|
||||
logger.info(
|
||||
`Trying to create trace, project ${apiScope.projectId}, id: ${internalId}`,
|
||||
);
|
||||
|
||||
const existingTrace = await prisma.trace.findFirst({
|
||||
@@ -470,7 +469,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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -478,7 +477,7 @@ export class TraceProcessor implements EventProcessor {
|
||||
existingTrace?.metadata
|
||||
? jsonSchema.parse(existingTrace.metadata)
|
||||
: undefined,
|
||||
body.metadata ?? undefined
|
||||
body.metadata ?? undefined,
|
||||
);
|
||||
|
||||
const mergedTags =
|
||||
@@ -553,15 +552,15 @@ export class ScoreProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
if (apiScope.accessLevel !== "scores" && apiScope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`
|
||||
`Access denied for score creation, ${apiScope.accessLevel}`,
|
||||
);
|
||||
}
|
||||
|
||||
async process(
|
||||
apiScope: ApiAccessScope
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
): Promise<Trace | Observation | Score> {
|
||||
const { body } = this.event;
|
||||
|
||||
@@ -579,7 +578,7 @@ export class ScoreProcessor implements EventProcessor {
|
||||
});
|
||||
if (existingScore && existingScore.projectId !== apiScope.projectId) {
|
||||
throw new ForbiddenError(
|
||||
`Access denied for score creation ${existingScore.projectId}`
|
||||
`Access denied for score creation ${existingScore.projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -613,13 +612,13 @@ export class SdkLogProcessor implements EventProcessor {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
auth(apiScope: ApiAccessScope) {
|
||||
auth(apiScope: LegacyIngestionAccessScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
process() {
|
||||
try {
|
||||
console.log("SDK Log", this.event);
|
||||
logger.info("SDK Log", this.event);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IngestionUtils } from "../IngestionUtils";
|
||||
import { IngestionEventType } from "../types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { env } from "../../../env";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export async function enqueueIngestionEvents(
|
||||
projectId: string,
|
||||
events: IngestionEventType[]
|
||||
events: IngestionEventType[],
|
||||
) {
|
||||
const ingestionFlushQueue = getIngestionFlushQueue();
|
||||
|
||||
@@ -31,8 +32,8 @@ export async function enqueueIngestionEvents(
|
||||
event,
|
||||
redis,
|
||||
ingestionFlushQueue,
|
||||
batchTimestamp
|
||||
)
|
||||
batchTimestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,11 +45,11 @@ async function enqueueSingleIngestionEvent(
|
||||
event: IngestionEventType,
|
||||
redis: Redis,
|
||||
ingestionFlushQueue: IngestionFlushQueue,
|
||||
batchTimestamp: string
|
||||
batchTimestamp: string,
|
||||
): Promise<void> {
|
||||
if (!("id" in event.body && event.body.id)) {
|
||||
console.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`
|
||||
logger.warn(
|
||||
`Received ingestion event without id: ${JSON.stringify(event)}`,
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
convertTraceUpsertEventsToRedisEvents,
|
||||
getTraceUpsertQueue,
|
||||
} from "../../redis/trace-upsert";
|
||||
import { ApiAccessScope, AuthHeaderVerificationResult } from "../../auth/types";
|
||||
import { ApiAccessScope } from "../../auth/types";
|
||||
import { redis } from "../../redis/redis";
|
||||
import { backOff } from "exponential-backoff";
|
||||
import { Model } from "../../..";
|
||||
import { enqueueIngestionEvents } from "./enqueueIngestionEvents";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export type BatchResult = {
|
||||
result: unknown;
|
||||
@@ -31,12 +32,27 @@ type TokenCountInput = {
|
||||
text: unknown;
|
||||
};
|
||||
|
||||
export type LegacyIngestionAccessScope = Omit<
|
||||
ApiAccessScope,
|
||||
"orgId" | "plan" | "rateLimitOverrides"
|
||||
>;
|
||||
|
||||
type LegacyIngestionAuthHeaderVerificationResult =
|
||||
| {
|
||||
validKey: true;
|
||||
scope: LegacyIngestionAccessScope;
|
||||
}
|
||||
| {
|
||||
validKey: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const handleBatch = async (
|
||||
events: z.infer<typeof ingestionApiSchema>["batch"],
|
||||
authCheck: AuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined
|
||||
authCheck: LegacyIngestionAuthHeaderVerificationResult,
|
||||
calculateTokenDelegate: (p: TokenCountInput) => number | undefined,
|
||||
) => {
|
||||
console.log(`handling ingestion ${events.length} events`);
|
||||
logger.info(`handling ingestion ${events.length} events`);
|
||||
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
|
||||
@@ -54,7 +70,7 @@ export const handleBatch = async (
|
||||
return await handleSingleEvent(
|
||||
singleEvent,
|
||||
authCheck.scope,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
});
|
||||
results.push({
|
||||
@@ -64,7 +80,7 @@ export const handleBatch = async (
|
||||
}); // Push each result into the array
|
||||
} catch (error) {
|
||||
// Handle or log the error if `handleSingleEvent` fails
|
||||
console.error("Error handling event:", error);
|
||||
logger.error("Error handling event:", error);
|
||||
// Decide how to handle the error: rethrow, continue, or push an error object to results
|
||||
// For example, push an error object:
|
||||
errors.push({
|
||||
@@ -78,9 +94,9 @@ export const handleBatch = async (
|
||||
if (env.CLICKHOUSE_URL) {
|
||||
try {
|
||||
await enqueueIngestionEvents(authCheck.scope.projectId, events);
|
||||
console.log(`Added ${events.length} ingestion events to queue`);
|
||||
logger.info(`Added ${events.length} ingestion events to queue`);
|
||||
} catch (err) {
|
||||
console.error("Error adding ingestion events to queue", err);
|
||||
logger.error("Error adding ingestion events to queue", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +108,10 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
|
||||
retry: (e: Error, attemptNumber: number) => {
|
||||
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
|
||||
console.log("not retrying auth error");
|
||||
logger.info("not retrying auth error");
|
||||
return false;
|
||||
}
|
||||
console.log(`retrying processing events ${attemptNumber}`);
|
||||
logger.info(`retrying processing events ${attemptNumber}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -103,11 +119,11 @@ async function retry<T>(request: () => Promise<T>): Promise<T> {
|
||||
|
||||
const handleSingleEvent = async (
|
||||
event: z.infer<typeof ingestionEvent>,
|
||||
apiScope: ApiAccessScope,
|
||||
apiScope: LegacyIngestionAccessScope,
|
||||
calculateTokenDelegate: (p: {
|
||||
model: Model;
|
||||
text: unknown;
|
||||
}) => number | undefined
|
||||
}) => number | undefined,
|
||||
) => {
|
||||
const { body } = event;
|
||||
let restEvent = body;
|
||||
@@ -122,8 +138,8 @@ const handleSingleEvent = async (
|
||||
restEvent = rest;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`
|
||||
logger.info(
|
||||
`handling single event ${event.id} of type ${event.type}: ${JSON.stringify({ body: restEvent })}`,
|
||||
);
|
||||
|
||||
const cleanedEvent = ingestionEvent.parse(cleanEvent(event));
|
||||
@@ -144,7 +160,7 @@ const handleSingleEvent = async (
|
||||
case eventTypes.GENERATION_UPDATE:
|
||||
processor = new ObservationProcessor(
|
||||
cleanedEvent,
|
||||
calculateTokenDelegate
|
||||
calculateTokenDelegate,
|
||||
);
|
||||
break;
|
||||
case eventTypes.SCORE_CREATE: {
|
||||
@@ -186,7 +202,7 @@ export function cleanEvent(obj: unknown): unknown {
|
||||
}
|
||||
|
||||
export const isNotNullOrUndefined = <T>(
|
||||
val?: T | null
|
||||
val?: T | null,
|
||||
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
|
||||
|
||||
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
@@ -194,7 +210,7 @@ export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
|
||||
|
||||
export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
batchResults: BatchResult[],
|
||||
projectId: string
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
const traceEvents: TraceUpsertEventType[] = batchResults
|
||||
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
|
||||
@@ -204,17 +220,17 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
"id" in result.result
|
||||
? // ingestion API only gets traces for one projectId
|
||||
{ traceId: result.result.id as string, projectId }
|
||||
: null
|
||||
: null,
|
||||
)
|
||||
.filter(isNotNullOrUndefined);
|
||||
|
||||
try {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via Redis`);
|
||||
|
||||
const queue = getTraceUpsertQueue();
|
||||
if (!queue) {
|
||||
console.error("TraceUpsertQueue not initialized");
|
||||
logger.error("TraceUpsertQueue not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,7 +240,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
env.LANGFUSE_WORKER_PASSWORD &&
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
) {
|
||||
console.log(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
logger.info(`Sending ${traceEvents.length} events to worker via HTTP`);
|
||||
const body: EventBodyType = {
|
||||
name: EventName.TraceUpsert,
|
||||
payload: traceEvents,
|
||||
@@ -238,7 +254,7 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
Authorization:
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD
|
||||
"admin" + ":" + env.LANGFUSE_WORKER_PASSWORD,
|
||||
).toString("base64"),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
@@ -247,6 +263,6 @@ export const sendToWorkerIfEnvironmentConfigured = async (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending events to worker", error);
|
||||
logger.error("Error sending events to worker", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidateAndInflateScoreParams = {
|
||||
};
|
||||
|
||||
export async function validateAndInflateScore(
|
||||
params: ValidateAndInflateScoreParams
|
||||
params: ValidateAndInflateScoreParams,
|
||||
): Promise<Score> {
|
||||
const { body, projectId } = params;
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!config || !validateDbScoreConfigSafe(config).success)
|
||||
throw new LangfuseNotFoundError(
|
||||
"The configId you provided does not match a valid config in this project"
|
||||
"The configId you provided does not match a valid config in this project",
|
||||
);
|
||||
|
||||
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
|
||||
@@ -47,7 +47,7 @@ export async function validateAndInflateScore(
|
||||
|
||||
if (!validation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`
|
||||
`Ingested score value type not valid against provided data type. Provide numeric values for numeric and boolean scores, and string values for categorical scores.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function inferDataType(value: string | number): ScoreDataType {
|
||||
|
||||
function mapStringValueToNumericValue(
|
||||
config: ValidatedScoreConfig,
|
||||
label: string
|
||||
label: string,
|
||||
): number | null {
|
||||
return (
|
||||
config.categories?.find((category) => category.label === label)?.value ??
|
||||
@@ -71,7 +71,7 @@ function mapStringValueToNumericValue(
|
||||
}
|
||||
|
||||
function inflateScoreBody(
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig }
|
||||
params: ValidateAndInflateScoreParams & { config?: ValidatedScoreConfig },
|
||||
): Score {
|
||||
const { body, projectId, scoreId, config } = params;
|
||||
|
||||
@@ -105,25 +105,25 @@ function inflateScoreBody(
|
||||
|
||||
function validateConfigAgainstBody(
|
||||
body: any,
|
||||
config: ValidatedScoreConfig
|
||||
config: ValidatedScoreConfig,
|
||||
): void {
|
||||
const { maxValue, minValue, categories, dataType: configDataType } = config;
|
||||
|
||||
if (body.dataType && body.dataType !== configDataType) {
|
||||
throw new InvalidRequestError(
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`
|
||||
`Data type mismatch based on config: expected ${configDataType}, got ${body.dataType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.isArchived) {
|
||||
throw new InvalidRequestError(
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first."
|
||||
"Config is archived and cannot be used to create new scores. Please restore the config first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.name !== body.name) {
|
||||
throw new InvalidRequestError(
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`
|
||||
`Name mismatch based on config: expected ${config.name}, got ${body.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function validateConfigAgainstBody(
|
||||
|
||||
if (!dataTypeValidation.success) {
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config data type.`
|
||||
`Ingested score body not valid against provided config data type.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function validateConfigAgainstBody(
|
||||
.join(", ");
|
||||
|
||||
throw new InvalidRequestError(
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`
|
||||
`Ingested score body not valid against provided config: ${errorDetails}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { env } from "../env";
|
||||
import winston from "winston";
|
||||
|
||||
const getWinstonLogger = (
|
||||
nodeEnv: "development" | "production" | "test",
|
||||
minLevel = "info",
|
||||
) => {
|
||||
const textLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.align(),
|
||||
winston.format.printf((info) => {
|
||||
const logMessage = `${info.timestamp} ${info.level} ${info.message}`;
|
||||
return info.stack ? `${logMessage}\n${info.stack}` : logMessage;
|
||||
}),
|
||||
);
|
||||
|
||||
const jsonLoggerFormat = winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
const format =
|
||||
env.LANGFUSE_LOG_FORMAT === "text" ? textLoggerFormat : jsonLoggerFormat;
|
||||
return winston.createLogger({
|
||||
level: minLevel,
|
||||
format: format,
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
};
|
||||
|
||||
export const logger = getWinstonLogger(env.NODE_ENV, env.LANGFUSE_LOG_LEVEL);
|
||||
+9
-8
@@ -2,8 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { ColumnDefinition } from "./tableDefinitions/types";
|
||||
import type { OrderByState } from "./interfaces/orderBy";
|
||||
import type { ColumnDefinition } from "../tableDefinitions/types";
|
||||
import type { OrderByState } from "../interfaces/orderBy";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Convert orderBy to SQL ORDER BY clause
|
||||
@@ -13,20 +14,20 @@ import type { OrderByState } from "./interfaces/orderBy";
|
||||
*/
|
||||
export function orderByToPrismaSql(
|
||||
orderBy: OrderByState,
|
||||
tableColumns: ColumnDefinition[]
|
||||
tableColumns: ColumnDefinition[],
|
||||
): Prisma.Sql {
|
||||
if (!orderBy) {
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC NULLS LAST`;
|
||||
return Prisma.sql`ORDER BY t.timestamp DESC`;
|
||||
}
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
// TODO: Only use id instead of name.
|
||||
// It's less error-prone & decouples data fetching from the human-readable UI labels
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column
|
||||
(c) => c.name === orderBy.column || c.id === orderBy.column,
|
||||
);
|
||||
|
||||
if (!col) {
|
||||
console.log("Invalid filter column", orderBy.column);
|
||||
logger.warn("Invalid filter column", orderBy.column);
|
||||
throw new Error("Invalid filter column: " + orderBy.column);
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@ export function orderByToPrismaSql(
|
||||
const orderByOrder = z.enum(["ASC", "DESC"]);
|
||||
const order = orderByOrder.safeParse(orderBy.order);
|
||||
if (!order.success) {
|
||||
console.log("Invalid order", orderBy.order);
|
||||
logger.warn("Invalid order", orderBy.order);
|
||||
throw new Error("Invalid order: " + orderBy.order);
|
||||
}
|
||||
|
||||
// Both column and order are safe, can use raw SQL
|
||||
return Prisma.raw(
|
||||
`ORDER BY ${col.internal} ${order.data} ${orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST"}`
|
||||
`ORDER BY ${col.internal} ${order.data} ${col.nullable ? (orderBy.order === "DESC" ? "NULLS LAST" : "NULLS FIRST") : ""}`,
|
||||
);
|
||||
}
|
||||
+22
-22
@@ -2,10 +2,10 @@ import { z } from "zod";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
|
||||
import { singleFilter } from "../interfaces/filters";
|
||||
import { orderBy } from "../interfaces/orderBy";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
import { sessionsViewCols } from "../tableDefinitions/index";
|
||||
import { sessionsViewCols } from "../../tableDefinitions";
|
||||
|
||||
const GetSessionTableSQLParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
@@ -16,8 +16,13 @@ const GetSessionTableSQLParamsSchema = z.object({
|
||||
});
|
||||
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
|
||||
|
||||
export const getSessionTableSQL = (
|
||||
params: GetSessionTableSQLParams
|
||||
export const createSessionsAllQuery = (
|
||||
select: Prisma.Sql,
|
||||
params: GetSessionTableSQLParams,
|
||||
options?: {
|
||||
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
|
||||
sessionIdList?: string[]; // used by session.metrics
|
||||
},
|
||||
): Prisma.Sql => {
|
||||
const { projectId, filter, orderBy, page, limit } =
|
||||
GetSessionTableSQLParamsSchema.parse(params);
|
||||
@@ -25,26 +30,17 @@ export const getSessionTableSQL = (
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
filter ?? [],
|
||||
sessionsViewCols,
|
||||
"sessions"
|
||||
"sessions",
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
|
||||
|
||||
const sessionIdFilter = options?.sessionIdList
|
||||
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
|
||||
: Prisma.sql``;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
s.id,
|
||||
s. "created_at" AS "createdAt",
|
||||
s.bookmarked,
|
||||
s.public,
|
||||
t. "userIds",
|
||||
t. "countTraces",
|
||||
o. "sessionDuration",
|
||||
o. "totalCost" AS "totalCost",
|
||||
o. "inputCost" AS "inputCost",
|
||||
o. "outputCost" AS "outputCost",
|
||||
o. "promptTokens" AS "promptTokens",
|
||||
o. "completionTokens" AS "completionTokens",
|
||||
o. "totalTokens" AS "totalTokens",
|
||||
(count(*) OVER ())::int AS "totalCount"
|
||||
${select}
|
||||
FROM
|
||||
trace_sessions AS s
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -54,9 +50,12 @@ export const getSessionTableSQL = (
|
||||
MIN(t. "timestamp") AS "min_timestamp",
|
||||
array_agg(t.id) AS "traceIds",
|
||||
array_agg(DISTINCT t.user_id) AS "userIds",
|
||||
count(t.id)::int AS "countTraces"
|
||||
count(t.id)::int AS "countTraces",
|
||||
array_agg(DISTINCT u.tag) AS "tags"
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
|
||||
WHERE
|
||||
t.project_id = ${projectId}
|
||||
AND t.session_id = s.id
|
||||
@@ -79,7 +78,8 @@ export const getSessionTableSQL = (
|
||||
WHERE
|
||||
s. "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
${sessionIdFilter}
|
||||
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit}
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
@@ -6,6 +6,7 @@ export enum EventName {
|
||||
BatchExport = "BatchExport",
|
||||
EvaluationExecution = "EvaluationExecution",
|
||||
LegacyIngestion = "LegacyIngestion",
|
||||
CloudUsageMetering = "CloudUsageMetering",
|
||||
}
|
||||
|
||||
export const LegacyIngestionEvent = z.object({
|
||||
@@ -60,6 +61,7 @@ export enum QueueName {
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
LegacyIngestionQueue = "legacy-ingestion-queue",
|
||||
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -69,6 +71,7 @@ export enum QueueJobs {
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
LegacyIngestionJob = "legacy-ingestion-job",
|
||||
CloudUsageMeteringJob = "cloud-usage-metering-job",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
null;
|
||||
@@ -8,9 +8,11 @@ let batchExportQueue: Queue<TQueueJobTypes[QueueName.BatchExport]> | null =
|
||||
export const getBatchExportQueue = () => {
|
||||
if (batchExportQueue) return batchExportQueue;
|
||||
|
||||
batchExportQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
batchExportQueue = connection
|
||||
? new Queue<TQueueJobTypes[QueueName.BatchExport]>(QueueName.BatchExport, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Queue } from "bullmq";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { redis } from "../redis/redis";
|
||||
import { createNewRedisInstance } from "../redis/redis";
|
||||
import { QueueName } from "../queues";
|
||||
|
||||
export type IngestionFlushQueue = Queue<null>;
|
||||
@@ -11,12 +11,14 @@ let ingestionFlushQueue: IngestionFlushQueue | null = null;
|
||||
export const getIngestionFlushQueue = () => {
|
||||
if (ingestionFlushQueue) return ingestionFlushQueue;
|
||||
|
||||
ingestionFlushQueue = redis
|
||||
const connection = createNewRedisInstance();
|
||||
|
||||
ingestionFlushQueue = connection
|
||||
? new Queue<null>(QueueName.IngestionFlushQueue, {
|
||||
connection: redis,
|
||||
connection: connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
|
||||
removeOnFail: 1000,
|
||||
removeOnFail: 1_000,
|
||||
delay: env.LANGFUSE_INGESTION_FLUSH_DELAY_MS,
|
||||
attempts: env.LANGFUSE_INGESTION_FLUSH_ATTEMPTS,
|
||||
},
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let legacyIngestionQueue: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
export class LegacyIngestionQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null = null;
|
||||
|
||||
export const getLegacyIngestionQueue = () => {
|
||||
if (legacyIngestionQueue) return legacyIngestionQueue;
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.LegacyIngestionQueue]
|
||||
> | null {
|
||||
if (LegacyIngestionQueue.instance) return LegacyIngestionQueue.instance;
|
||||
|
||||
legacyIngestionQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: redis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
const newRedis = createNewRedisInstance({ enableOfflineQueue: false });
|
||||
|
||||
LegacyIngestionQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.LegacyIngestionQueue]>(
|
||||
QueueName.LegacyIngestionQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
: null;
|
||||
)
|
||||
: null;
|
||||
|
||||
return legacyIngestionQueue;
|
||||
};
|
||||
return LegacyIngestionQueue.instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import Redis from "ioredis";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export const createNewRedisInstance = (
|
||||
additionalOptions: Partial<RedisOptions> = {},
|
||||
) => {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
...additionalOptions,
|
||||
})
|
||||
: null;
|
||||
};
|
||||
|
||||
const createRedisClient = () => {
|
||||
try {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
|
||||
: env.REDIS_HOST
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
password: String(env.REDIS_AUTH),
|
||||
maxRetriesPerRequest: null, // Set to `null` to disable retrying
|
||||
})
|
||||
: null;
|
||||
return createNewRedisInstance();
|
||||
} catch (e) {
|
||||
console.error(e, "Failed to connect to redis");
|
||||
logger.error("Failed to connect to redis", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TraceUpsertEventType,
|
||||
} from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { redis } from "./redis";
|
||||
import { createNewRedisInstance } from "./redis";
|
||||
|
||||
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
@@ -14,9 +14,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: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, PrismaClient } from "@prisma/client";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class PromptService {
|
||||
private cacheEnabled: boolean;
|
||||
@@ -9,9 +10,9 @@ 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
|
||||
cacheEnabled?: boolean, // used for testing
|
||||
) {
|
||||
this.cacheEnabled =
|
||||
Boolean(redis) &&
|
||||
@@ -25,7 +26,7 @@ export class PromptService {
|
||||
const cachedPrompt = await this.getCachedPrompt(params);
|
||||
|
||||
this.incrementMetric(
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss,
|
||||
);
|
||||
|
||||
if (cachedPrompt) {
|
||||
@@ -117,7 +118,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async lockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -133,7 +134,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
public async unlockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -149,7 +150,7 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private async isCacheLocked(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<boolean> {
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
@@ -163,14 +164,14 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getLockKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
// Important to *pre*fix LOCK as otherwise it would be deleted by deleteKeysByPrefix
|
||||
return `LOCK:${this.getCacheKeyPrefix(params)}`;
|
||||
}
|
||||
|
||||
public async invalidateCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
@@ -187,7 +188,7 @@ export class PromptService {
|
||||
await this.redis?.del([...(keys ?? []), keyIndexKey]);
|
||||
|
||||
this.logInfo(
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
|
||||
@@ -203,23 +204,23 @@ export class PromptService {
|
||||
}
|
||||
|
||||
private getCacheKeyPrefix(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private getKeyIndexKey(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
params: Pick<PromptParams, "projectId" | "promptName">,
|
||||
): string {
|
||||
return `prompt_key_index:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private logError(message: string, ...args: any[]) {
|
||||
console.error(`[PromptService] ${message}`, ...args);
|
||||
logger.error(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private logInfo(message: string, ...args: any[]) {
|
||||
console.log(`[PromptService] ${message}`, ...args);
|
||||
logger.info(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private incrementMetric(name: Metrics, value: number = 1) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Readable } from "stream";
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { logger } from "../logger";
|
||||
|
||||
type UploadFile = {
|
||||
fileName: string;
|
||||
@@ -56,15 +57,14 @@ export class S3StorageService {
|
||||
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
logger.error(err);
|
||||
throw new Error("Failed to upload to S3 or generate signed URL");
|
||||
}
|
||||
}
|
||||
|
||||
private async getSignedUrl(
|
||||
fileName: string,
|
||||
ttlSeconds: number
|
||||
ttlSeconds: number,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await getSignedUrl(
|
||||
@@ -74,7 +74,7 @@ export class S3StorageService {
|
||||
Key: fileName,
|
||||
ResponseContentDisposition: `attachment; filename="${fileName}"`,
|
||||
}),
|
||||
{ expiresIn: ttlSeconds }
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
} catch (err) {
|
||||
throw Error("Failed to generate signed URL");
|
||||
|
||||
+4
-4
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import { BatchExportSuccessEmailTemplate } from "./BatchExportSuccessEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
type SendBatchExportSuccessParams = {
|
||||
env: Partial<
|
||||
@@ -24,8 +25,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
expiresInHours,
|
||||
}: SendBatchExportSuccessParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error("Missing environment variables for sending email.");
|
||||
|
||||
logger.error("Missing environment variables for sending email.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
userName,
|
||||
batchExportName,
|
||||
expiresInHours,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -51,6 +51,6 @@ export const sendBatchExportSuccessEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
+7
-6
@@ -3,6 +3,7 @@ import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import MembershipInvitationTemplate from "./MembershipInvitationEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
const langfuseUrls = {
|
||||
US: "https://us.cloud.langfuse.com",
|
||||
@@ -34,8 +35,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
orgName,
|
||||
}: SendMembershipInvitationParams) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
console.error(
|
||||
"Missing environment variables for sending membership invitation email."
|
||||
logger.error(
|
||||
"Missing environment variables for sending membership invitation email.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -49,8 +50,8 @@ export const sendMembershipInvitationEmail = async ({
|
||||
|
||||
const authUrl = getAuthURL();
|
||||
if (!authUrl) {
|
||||
console.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable."
|
||||
logger.error(
|
||||
"Missing NEXTAUTH_URL or NEXT_PUBLIC_LANGFUSE_CLOUD_REGION environment variable.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export const sendMembershipInvitationEmail = async ({
|
||||
inviteLink: authUrl,
|
||||
emailFromAddress: env.EMAIL_FROM_ADDRESS,
|
||||
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -77,6 +78,6 @@ export const sendMembershipInvitationEmail = async ({
|
||||
html: htmlTemplate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "arrayOptions",
|
||||
internal: 't."userIds"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session Duration (s)",
|
||||
@@ -78,10 +79,18 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
type: "number",
|
||||
internal: 'o."totalTokens"',
|
||||
},
|
||||
{
|
||||
name: "Trace Tags",
|
||||
id: "tags",
|
||||
type: "arrayOptions",
|
||||
internal: 't."tags"',
|
||||
options: [], // to be filled in at runtime
|
||||
},
|
||||
];
|
||||
|
||||
export type SessionOptions = {
|
||||
userIds: Array<OptionsDefinition>;
|
||||
tags: Array<OptionsDefinition>;
|
||||
};
|
||||
|
||||
export function sessionsTableColsWithOptions(
|
||||
@@ -91,6 +100,9 @@ export function sessionsTableColsWithOptions(
|
||||
if (col.id === "userIds") {
|
||||
return { ...col, options: options?.userIds ?? [] };
|
||||
}
|
||||
if (col.id === "tags") {
|
||||
return { ...col, options: options?.tags ?? [] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ColumnDefinition =
|
||||
id: string;
|
||||
type: "number" | "string" | "datetime" | "boolean";
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -16,6 +17,7 @@ export type ColumnDefinition =
|
||||
type: "stringOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -23,6 +25,7 @@ export type ColumnDefinition =
|
||||
type: "arrayOptions";
|
||||
options: Array<OptionsDefinition>;
|
||||
internal: string;
|
||||
nullable?: boolean;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
@@ -30,6 +33,7 @@ export type ColumnDefinition =
|
||||
type: "stringObject" | "numberObject";
|
||||
internal: string;
|
||||
keyOptions?: Array<string>;
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
export const tableNames = [
|
||||
|
||||
@@ -15,6 +15,7 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "stringOptions",
|
||||
internal: 't."name"',
|
||||
options: [], // to be filled in at runtime
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Timestamp",
|
||||
@@ -22,12 +23,19 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
type: "datetime",
|
||||
internal: 't."timestamp"',
|
||||
},
|
||||
{ name: "User ID", id: "userId", type: "string", internal: 't."user_id"' },
|
||||
{
|
||||
name: "User ID",
|
||||
id: "userId",
|
||||
type: "string",
|
||||
internal: 't."user_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Session ID",
|
||||
id: "sessionId",
|
||||
type: "string",
|
||||
internal: 't."session_id"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Metadata",
|
||||
@@ -40,12 +48,14 @@ const tracesOnlyCols: ColumnDefinition[] = [
|
||||
id: "version",
|
||||
type: "string",
|
||||
internal: 't."version"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Release",
|
||||
id: "release",
|
||||
type: "string",
|
||||
internal: 't."release"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Level",
|
||||
@@ -69,24 +79,28 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."promptTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Tokens",
|
||||
id: "outputTokens",
|
||||
type: "number",
|
||||
internal: 'tm."completionTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Tokens",
|
||||
id: "totalTokens",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
id: "usage",
|
||||
type: "number",
|
||||
internal: 'tm."totalTokens"',
|
||||
nullable: true,
|
||||
},
|
||||
|
||||
{
|
||||
@@ -106,18 +120,21 @@ export const tracesTableCols: ColumnDefinition[] = [
|
||||
id: "inputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedInputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Output Cost ($)",
|
||||
id: "outputCost",
|
||||
type: "number",
|
||||
internal: '"calculatedOutputCost"',
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Total Cost ($)",
|
||||
id: "totalCost",
|
||||
type: "number",
|
||||
internal: '"calculatedTotalCost"',
|
||||
nullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Generated
+3985
-2103
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -41,6 +41,13 @@ ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST
|
||||
ARG NEXT_PUBLIC_CRISP_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_LANGFUSE_TRACING_SAMPLE_RATE
|
||||
|
||||
# Accept build id as NEXT_PUBLIC_BUILD_ID or PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG PORTER_NEXT_PUBLIC_BUILD_ID
|
||||
ARG NEXT_PUBLIC_BUILD_ID
|
||||
ENV NEXT_PUBLIC_BUILD_ID=${PORTER_NEXT_PUBLIC_BUILD_ID:-$NEXT_PUBLIC_BUILD_ID}
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
@@ -54,7 +61,7 @@ RUN rm -f ./web/src/middleware.ts
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
|
||||
RUN turbo run build --filter=web
|
||||
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
|
||||
@@ -100,4 +107,4 @@ ENV PORT 3000
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command
|
||||
CMD ["node", "./web/server.js"]
|
||||
CMD ["node", "./web/server.js", "--keepAliveTimeout", "110000"]
|
||||
|
||||
+78
-46
@@ -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,
|
||||
@@ -125,36 +149,44 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const sentryOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// See the sections below for information on the following options:
|
||||
// 'Configure Source Maps':
|
||||
// - disableServerWebpackPlugin
|
||||
// - disableClientWebpackPlugin
|
||||
// - hideSourceMaps
|
||||
hideSourceMaps: true,
|
||||
// - widenClientFileUpload
|
||||
// 'Configure Legacy Browser Support':
|
||||
// - transpileClientSDK
|
||||
// 'Configure Serverside Auto-instrumentation':
|
||||
// - autoInstrumentServerFunctions
|
||||
// - excludeServerRoutes
|
||||
// 'Configure Tunneling':
|
||||
// - tunnelRoute
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/api/monitoring-tunnel",
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, sentryOptions);
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
});
|
||||
|
||||
+58
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.71.0",
|
||||
"version": "2.78.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -23,6 +23,8 @@
|
||||
},
|
||||
"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",
|
||||
@@ -37,33 +39,47 @@
|
||||
"@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-winston": "^0.40.0",
|
||||
"@opentelemetry/instrumentation-undici": "^0.4.0",
|
||||
"@opentelemetry/resources": "^1.25.1",
|
||||
"@opentelemetry/sdk-metrics": "1.23.0",
|
||||
"@opentelemetry/sdk-node": "^0.50.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.25.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||
"@prisma/instrumentation": "^5.13.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@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",
|
||||
@@ -73,29 +89,30 @@
|
||||
"@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",
|
||||
"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.436.0",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -103,20 +120,23 @@
|
||||
"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",
|
||||
@@ -135,10 +155,10 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
@@ -146,17 +166,17 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"eslint-config-next": "^14.2.6",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.7.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { v4 } from "uuid";
|
||||
import { JobExecutionStatus, prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
import { JobExecutionStatus, Prisma, prisma } from "@langfuse/shared/src/db";
|
||||
import { OrgEnrichedApiKey, redis } from "@langfuse/shared/src/server";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { ApiKeyZod } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
const generateAuth = (username: string, password: string) => {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString("base64");
|
||||
@@ -38,6 +37,14 @@ describe("Health endpoints", () => {
|
||||
});
|
||||
|
||||
describe("Ingestion Pipeline", () => {
|
||||
beforeEach(async () => {
|
||||
// clear the redis cache
|
||||
const keys = await redis?.keys("*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("ingest a trace", async () => {
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
@@ -118,7 +125,7 @@ describe("Ingestion Pipeline", () => {
|
||||
expect(redisKeys?.length).toBe(1);
|
||||
const redisValue = await redis?.get(redisKeys![0]);
|
||||
|
||||
const llmApiKey = ApiKeyZod.parse(JSON.parse(redisValue!));
|
||||
const llmApiKey = OrgEnrichedApiKey.parse(JSON.parse(redisValue!));
|
||||
expect(llmApiKey.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
});
|
||||
|
||||
@@ -143,6 +150,91 @@ describe("Ingestion Pipeline", () => {
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
}, 25000);
|
||||
|
||||
it("rate limit ingestion", async () => {
|
||||
// update the org in the database and set the rate limit to 1 for ingestion
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
});
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
...(typeof org?.cloudConfig === "object" ? org.cloudConfig : {}),
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 1,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const traceId = v4();
|
||||
const spanId = v4();
|
||||
|
||||
const event = {
|
||||
batch: [
|
||||
{
|
||||
id: v4(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
name: "test trace",
|
||||
id: traceId,
|
||||
userId: "user-1", // triggers the eval
|
||||
},
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: spanId,
|
||||
traceId: traceId,
|
||||
name: "test span",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// Arrange
|
||||
const url = "http://localhost:3000/api/public/ingestion";
|
||||
|
||||
// Act
|
||||
let responses = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
responses.push(
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: userApiKeyAuth,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// check that at least one of the responses is a 429
|
||||
const rateLimitedResponse = responses.find((r) => r.status === 429);
|
||||
expect(rateLimitedResponse).not.toBeNull();
|
||||
|
||||
// revert the rate limit on the org
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: org?.cloudConfig ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prompts endpoint", () => {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
getDisplaySecretKey,
|
||||
hashSecretKey,
|
||||
OrgEnrichedApiKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
ApiAuthService,
|
||||
ApiKeyZod,
|
||||
} from "@/src/features/public-api/server/apiAuth";
|
||||
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Prisma, type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
|
||||
describe("Authenticate API calls", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +66,118 @@ describe("Authenticate API calls", () => {
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should create new api key with stripe data and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
stripe: {
|
||||
customerId: "cus_test123",
|
||||
activeSubscriptionId: "sub_test123",
|
||||
activeProductId: "prod_test123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create new api key with custom rate limits and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, null).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
if (auth.validKey) {
|
||||
expect(auth.scope.orgId).toBe("seed-org-id");
|
||||
expect(auth.scope.plan).toBe("cloud:hobby");
|
||||
expect(auth.scope.rateLimitOverrides).toEqual([
|
||||
{
|
||||
resource: "ingestion",
|
||||
points: 100,
|
||||
durationInSec: 60,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
id: "seed-org-id",
|
||||
},
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
@@ -149,6 +259,25 @@ describe("Authenticate API calls", () => {
|
||||
it("should create new api key and read from cache", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// update the organization with a cloud config
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: {
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
@@ -177,12 +306,31 @@ describe("Authenticate API calls", () => {
|
||||
|
||||
expect(cachedKey2).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey2!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
rateLimitOverrides: [
|
||||
{
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
durationInSec: 60,
|
||||
},
|
||||
{
|
||||
resource: "ingestion",
|
||||
},
|
||||
],
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id: "seed-org-id" },
|
||||
data: {
|
||||
cloudConfig: Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("searching for non-existing key stores flag in redis and fails auth", async () => {
|
||||
@@ -275,7 +423,7 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -288,6 +436,8 @@ describe("Authenticate API calls", () => {
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
projectId: expect.any(String),
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,10 +522,12 @@ describe("Authenticate API calls", () => {
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
const parsed = OrgEnrichedApiKey.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
orgId: "seed-org-id",
|
||||
plan: "cloud:hobby",
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -396,6 +548,167 @@ describe("Authenticate API calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidates api keys in redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("before each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
console.log("before each deleting keys. actually deleting", keys);
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// if we do not remove the key, it will remain in the cache and
|
||||
// calling the test twice will not add the key to the cache
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
console.log("after each deleting keys", keys);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should invalidate organization API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating org keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateOrgApiKeys(
|
||||
"seed-org-id",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should invalidate project API keys in redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// put keys into cache
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const invalidatedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(invalidatedCachedKey).toBeNull();
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
|
||||
it("if no keys in redis, invalidating project keys without fast hash should do nothing", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
data: {
|
||||
fastHashedSecretKey: Math.random().toString(36).substring(2, 15),
|
||||
},
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).invalidateProjectApiKeys(
|
||||
"7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
);
|
||||
|
||||
const keys = await redis.keys("api-key*");
|
||||
expect(keys.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
const seedApiKey = {
|
||||
id: "seed-api-key",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { orderByToPrismaSql } from "@langfuse/shared/src/server";
|
||||
import { tracesTableCols } from "@langfuse/shared";
|
||||
|
||||
// The test for the orderByToPrisma function
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
describe("RateLimitService", () => {
|
||||
const orgId = "seed-org-id";
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(() => {
|
||||
redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
expect(redis).toBeDefined();
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys?.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keys = await redis?.keys("rate-limit*");
|
||||
if (keys && keys.length > 0) {
|
||||
await redis?.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
it("should rate limit", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 999,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
|
||||
// check redis for the rate limit key
|
||||
const value = await redis.get("rate-limit:public-api:seed-org-id");
|
||||
|
||||
expect(value).toBeDefined();
|
||||
expect(parseInt(value ?? "0")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should increment the rate limit count", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 1000,
|
||||
remainingPoints: 998,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the rate limit count after the window expires", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 100, durationInSec: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
const firstResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
expect(firstResult?.isRateLimited()).toBe(false);
|
||||
|
||||
expect(firstResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 98,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const secondResult = await rateLimitService.rateLimitRequest(
|
||||
scope,
|
||||
"public-api",
|
||||
);
|
||||
|
||||
expect(secondResult?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 100,
|
||||
remainingPoints: 99,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
|
||||
expect(secondResult?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when rate limit is exceeded", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 60 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
}
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 0,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 6,
|
||||
isFirstInDuration: false,
|
||||
});
|
||||
expect(result?.isRateLimited()).toBe(true);
|
||||
});
|
||||
|
||||
it("should apply rate limits with override for specific resource", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 5,
|
||||
remainingPoints: 4,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not apply rate limits for resource prompts", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "prompts");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for ingestion when overridden to null in API key", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "ingestion" as const, points: null, durationInSec: null },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "ingestion");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not apply rate limits when redis is not defined", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "cloud:hobby" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [
|
||||
{ resource: "public-api" as const, points: 5, durationInSec: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(null);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not apply rate limits for OSS plan", async () => {
|
||||
const scope = {
|
||||
orgId: orgId,
|
||||
plan: "oss" as const,
|
||||
projectId: "test-project-id",
|
||||
accessLevel: "all" as const,
|
||||
rateLimitOverrides: [],
|
||||
};
|
||||
|
||||
const rateLimitService = new RateLimitService(redis!);
|
||||
|
||||
const result = await rateLimitService.rateLimitRequest(scope, "public-api");
|
||||
|
||||
expect(result?.res).toBeUndefined();
|
||||
expect(result?.isRateLimited()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type z } from "zod";
|
||||
|
||||
@@ -24,10 +21,7 @@ export const pruneDatabase = async () => {
|
||||
await prisma.llmApiKeys.deleteMany();
|
||||
};
|
||||
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string,
|
||||
): string {
|
||||
function createBasicAuthHeader(username: string, password: string): string {
|
||||
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
|
||||
"base64",
|
||||
);
|
||||
@@ -97,38 +91,3 @@ export async function makeZodVerifiedAPICall<T extends z.ZodTypeAny>(
|
||||
}
|
||||
return { body: resBody, status };
|
||||
}
|
||||
|
||||
export const setupUserAndProject = async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: "user-1",
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
name: "llm-app",
|
||||
apiKeys: {
|
||||
create: [
|
||||
{
|
||||
note: "seeded key",
|
||||
hashedSecretKey: await hashSecretKey("sk-lf-1234567890"),
|
||||
displaySecretKey: getDisplaySecretKey("sk-lf-1234567890"),
|
||||
publicKey: "pk-lf-1234567890",
|
||||
},
|
||||
],
|
||||
},
|
||||
projectMembers: {
|
||||
create: {
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, project };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { stripeWebhookApiHandler } from "@/src/ee/features/billing/server/stripeWebhookApiHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = stripeWebhookApiHandler;
|
||||
@@ -0,0 +1,16 @@
|
||||
export const metadata = {
|
||||
title: 'Next.js',
|
||||
description: 'Generated by Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
id={message.id}
|
||||
className="height-[auto] min-h-8 w-full pt-3 font-mono text-xs focus:outline-none"
|
||||
className="height-[auto] min-h-6 w-full font-mono text-xs focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
value={message.content}
|
||||
onChange={handleContentChange}
|
||||
|
||||
@@ -24,13 +24,21 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
}, [scrollAreaRef, messages.length]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-4">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent {...{ message, ...props }} key={message.id} />
|
||||
);
|
||||
})}
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-2 font-semibold">Messages</div>
|
||||
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-3">
|
||||
{props.messages.map((message) => {
|
||||
return (
|
||||
<ChatMessageComponent
|
||||
{...{ message, ...props }}
|
||||
key={message.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<AddMessageButton {...props} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +60,10 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
<Button
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="w-full space-x-2 py-6"
|
||||
className="w-full"
|
||||
onClick={() => addMessage(nextMessageRole)}
|
||||
>
|
||||
<PlusCircleIcon size={16} />
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<Link href={`/project/${p.projectId}/settings#llm-api-keys`}>
|
||||
<Link href={`/project/${p.projectId}/settings/api-keys`}>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
@@ -60,7 +60,7 @@ export const LLMApiKeyComponent = (p: {
|
||||
{`No LLM API key found for provider ${modelProvider}.`}
|
||||
|
||||
<Link
|
||||
href={`/project/${p.projectId}/settings`}
|
||||
href={`/project/${p.projectId}/settings/api-keys`}
|
||||
className="flex flex-row"
|
||||
>
|
||||
Create a new LLM API key here. <ArrowTopRightIcon />
|
||||
|
||||
@@ -31,12 +31,15 @@ export type ModelParamsContext = {
|
||||
formDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
export const ModelParameters: React.FC<
|
||||
ModelParamsContext & { evalModelsOnly: boolean }
|
||||
> = ({
|
||||
modelParams,
|
||||
availableProviders,
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
evalModelsOnly,
|
||||
formDisabled = false,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
@@ -48,8 +51,11 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
<p className="font-semibold">Model</p>
|
||||
{availableProviders.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm">No LLM API key set in project.</p>
|
||||
<CreateLLMApiKeyDialog />
|
||||
<p className="text-xs">
|
||||
No LLM API key set in project.{" "}
|
||||
{evalModelsOnly && "For evals, only OpenAI models are supported."}
|
||||
</p>
|
||||
<CreateLLMApiKeyDialog evalModelsOnly={evalModelsOnly} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -49,7 +49,7 @@ export const PagedSettingsContainer = ({
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4 py-4 md:gap-8">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<div className="grid w-full items-start gap-4 md:grid-cols-[150px_1fr] lg:grid-cols-[220px_1fr]">
|
||||
<nav className="block md:hidden">
|
||||
<Select
|
||||
onValueChange={(slug) => {
|
||||
|
||||
@@ -11,11 +11,11 @@ export const SettingsDangerZone: React.FC<{
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Header title="Danger Zone" level="h3" />
|
||||
<div className="rounded border">
|
||||
<div className="rounded-lg border">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
|
||||
className="flex items-center justify-between gap-4 border-b p-3 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold">{item.title}</h4>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadgeCheck,
|
||||
Github,
|
||||
HardDriveDownload,
|
||||
Info,
|
||||
Map,
|
||||
Newspaper,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import { api } from "@/src/utils/api";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useIsEeEnabled } from "@/src/ee/utils/useIsEeEnabled";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
const checkUpdate = api.public.checkUpdate.useQuery(undefined, {
|
||||
@@ -30,6 +32,7 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
onError: (error) => console.error("checkUpdate error", error), // do not render default error message
|
||||
});
|
||||
const isEeVersion = useIsEeEnabled();
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
const hasUpdate =
|
||||
!env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION &&
|
||||
@@ -46,8 +49,9 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className={className}>
|
||||
<Button variant="ghost" size="xs" className={cn("text-xs",className)}>
|
||||
{VERSION}
|
||||
{!isLangfuseCloud && (isEeVersion ? " EE" : " OSS")}
|
||||
{hasUpdate && <ArrowUp className={`ml-1 h-3 w-3 ${color}`} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -96,6 +100,17 @@ export const VersionLabel = ({ className }: { className?: string }) => {
|
||||
Roadmap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isLangfuseCloud && !isEeVersion && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://langfuse.com/docs/deployment/feature-overview"
|
||||
target="_blank"
|
||||
>
|
||||
<Info size={16} className="mr-2" />
|
||||
Compare Versions
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -9,15 +9,14 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
|
||||
import {
|
||||
DEFAULT_AGGREGATION_SELECTION,
|
||||
tableDateRangeAggregationSettings,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type TableDateRangeOptions,
|
||||
DASHBOARD_AGGREGATION_OPTIONS,
|
||||
type DashboardDateRange,
|
||||
TABLE_RANGE_DROPDOWN_OPTIONS,
|
||||
TABLE_AGGREGATION_OPTIONS,
|
||||
getDateFromOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
@@ -101,24 +100,22 @@ export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
setDateRangeAndOption,
|
||||
}) => {
|
||||
const onDropDownSelection = (value: TableDateRangeOptions) => {
|
||||
if (value === DEFAULT_AGGREGATION_SELECTION) {
|
||||
setDateRangeAndOption(DEFAULT_AGGREGATION_SELECTION, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
tableDateRangeAggregationSettings[
|
||||
value as keyof typeof tableDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting),
|
||||
to: new Date(),
|
||||
const dateFromOption = getDateFromOption({
|
||||
filterSource: "TABLE",
|
||||
option: value,
|
||||
});
|
||||
|
||||
const initialDateRange = !!dateFromOption
|
||||
? { from: dateFromOption, to: new Date() }
|
||||
: undefined;
|
||||
|
||||
setDateRangeAndOption(value, initialDateRange);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={TABLE_RANGE_DROPDOWN_OPTIONS}
|
||||
options={TABLE_AGGREGATION_OPTIONS}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
|
||||
export const ErrorPage = ({
|
||||
title = "Error",
|
||||
|
||||
@@ -45,7 +45,7 @@ export function JsonEditor({
|
||||
if (onChange) onChange(c);
|
||||
setLinterEnabled(c !== "");
|
||||
}}
|
||||
className={cn("overflow-hidden rounded-md border", className)}
|
||||
className={cn("overflow-hidden rounded-md border text-xs", className)}
|
||||
editable={editable}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export const FullScreenPage: React.FC<React.PropsWithChildren<{}>> = ({
|
||||
children,
|
||||
}) => {
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const FullScreenPage: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
mobile?: boolean;
|
||||
className?: string;
|
||||
}>
|
||||
> = ({ children, mobile = true, className }) => {
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-6rem)] flex-col overflow-hidden lg:h-[calc(100dvh-2rem)]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden lg:h-[calc(100dvh-1.5rem)]",
|
||||
mobile && "h-[calc(100dvh-6rem)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,10 @@ export default function Header({
|
||||
title: string;
|
||||
breadcrumb?: { name: string; href?: string }[];
|
||||
status?: Status;
|
||||
label?: {
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
help?: { description: string; href?: string };
|
||||
featureBetaURL?: string;
|
||||
actionButtons?: React.ReactNode;
|
||||
@@ -91,6 +95,11 @@ export default function Header({
|
||||
) : null}
|
||||
</div>
|
||||
{props.status && <StatusBadge type={props.status} />}
|
||||
{props.label && (
|
||||
<Link href={props.label.href}>
|
||||
<StatusBadge type={props.label.text} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{props.actionButtons ?? null}
|
||||
@@ -203,7 +212,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
@@ -290,7 +299,7 @@ const BreadcrumbComponent = ({
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-my-1 ml-4 mr-1 hover:bg-background"
|
||||
className="-my-1 ml-4 hover:bg-background"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -118,6 +118,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
const uiCustomization = useUiCustomization();
|
||||
|
||||
const cloudAdmin =
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
|
||||
session.data?.user?.admin === true;
|
||||
|
||||
// project info based on projectId in the URL
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
|
||||
@@ -132,24 +136,25 @@ export default function Layout(props: PropsWithChildren) {
|
||||
|
||||
// Feature Flags
|
||||
if (
|
||||
!(
|
||||
route.featureFlag === undefined ||
|
||||
enableExperimentalFeatures ||
|
||||
session.data?.user?.featureFlags[route.featureFlag]
|
||||
)
|
||||
route.featureFlag !== undefined &&
|
||||
!enableExperimentalFeatures &&
|
||||
!cloudAdmin &&
|
||||
session.data?.user?.featureFlags[route.featureFlag] !== true
|
||||
)
|
||||
return null;
|
||||
|
||||
// check entitlements
|
||||
if (
|
||||
route.entitlement !== undefined &&
|
||||
!entitlements.includes(route.entitlement)
|
||||
!entitlements.includes(route.entitlement) &&
|
||||
!cloudAdmin
|
||||
)
|
||||
return null;
|
||||
|
||||
// RBAC
|
||||
if (
|
||||
route.projectRbacScope !== undefined &&
|
||||
!cloudAdmin &&
|
||||
(!project ||
|
||||
!organization ||
|
||||
!hasProjectAccess({
|
||||
@@ -345,7 +350,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition.Root>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-52 lg:flex-col">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex h-screen grow flex-col border-r border-border bg-background">
|
||||
<nav className="flex h-full flex-1 flex-col overflow-y-auto px-4 py-3">
|
||||
@@ -417,7 +422,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -485,7 +490,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span
|
||||
className="mb-1 block max-w-56 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
className="mb-1 block max-w-52 overflow-hidden truncate border-b px-3 pb-2 text-sm leading-6 text-muted-foreground"
|
||||
title={session.data?.user?.email ?? undefined}
|
||||
>
|
||||
{session.data?.user?.email}
|
||||
@@ -510,8 +515,9 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="lg:pl-56">
|
||||
{env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
<div className="lg:pl-52">
|
||||
{env.NEXT_PUBLIC_DEMO_ORG_ID &&
|
||||
env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
routerProjectId === env.NEXT_PUBLIC_DEMO_PROJECT_ID &&
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) ? (
|
||||
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
@@ -530,7 +536,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<main className="p-4">{props.children}</main>
|
||||
<main className="p-3">{props.children}</main>
|
||||
<Toaster visibleToasts={1} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -632,7 +638,7 @@ const MainNavigation: React.FC<{
|
||||
{item.label && (
|
||||
<span
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 text-xs",
|
||||
item.current
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
@@ -646,7 +652,7 @@ const MainNavigation: React.FC<{
|
||||
open
|
||||
? "rotate-90 text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
"ml-auto h-5 w-5 shrink-0",
|
||||
"ml-auto h-4 w-4 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -661,7 +667,7 @@ const MainNavigation: React.FC<{
|
||||
subItem.current
|
||||
? "bg-primary-foreground text-primary-accent"
|
||||
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1 pl-7 pr-2 text-sm",
|
||||
)}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const ROUTES: Route[] = [
|
||||
name: "Langfuse",
|
||||
pathname: "/",
|
||||
icon: LangfuseIcon,
|
||||
label: <VersionLabel />,
|
||||
label: <VersionLabel className="-ml-3" />,
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
|
||||
@@ -11,28 +11,32 @@ const statusCategories = {
|
||||
export type Status =
|
||||
(typeof statusCategories)[keyof typeof statusCategories][number];
|
||||
|
||||
export const StatusBadge = (props: { className?: string; type: Status }) => {
|
||||
export const StatusBadge = (props: {
|
||||
className?: string;
|
||||
type: Status | (string & {});
|
||||
}) => {
|
||||
let badgeColor = "bg-muted-gray text-primary";
|
||||
let dotColor = "bg-muted-foreground";
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = true;
|
||||
let showDot = false;
|
||||
|
||||
if (statusCategories.active.includes(props.type)) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
showDot = true;
|
||||
} else if (statusCategories.pending.includes(props.type)) {
|
||||
badgeColor = "bg-light-yellow text-dark-yellow";
|
||||
dotColor = "animate-ping bg-dark-yellow";
|
||||
dotPingColor = "bg-dark-yellow";
|
||||
showDot = true;
|
||||
} else if (statusCategories.error.includes(props.type)) {
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
showDot = false;
|
||||
showDot = true;
|
||||
} else if (statusCategories.completed.includes(props.type)) {
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// OpenAI API Content Schema defined as per https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages, 20.08.2024
|
||||
const OpenAITextContentPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const OpenAIUrlImageUrl = z.string().regex(/^https?:/);
|
||||
|
||||
const OpenAIBase64ImageUrl = z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg|jpg|gif|webp);base64,/);
|
||||
|
||||
const OpenAIImageContentPart = z.object({
|
||||
type: z.literal("image_url"),
|
||||
image_url: z.object({
|
||||
url: z.union([OpenAIUrlImageUrl, OpenAIBase64ImageUrl]),
|
||||
detail: z.enum(["low", "high", "auto"]).optional(), // Controls how the model processes the image. Defaults to "auto". [https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding]
|
||||
}),
|
||||
});
|
||||
|
||||
export const OpenAIContentParts = z.array(
|
||||
z.union([OpenAITextContentPart, OpenAIImageContentPart]),
|
||||
);
|
||||
|
||||
export const OpenAIContentSchema = z.union([z.string(), OpenAIContentParts]);
|
||||
export type OpenAIContentSchema = z.infer<typeof OpenAIContentSchema>;
|
||||
|
||||
export const ChatMlMessageSchema = z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
content: z
|
||||
.union([z.record(z.any()), z.string(), z.array(z.any())])
|
||||
.union([
|
||||
z.record(z.any()),
|
||||
z.string(),
|
||||
z.array(z.any()),
|
||||
OpenAIContentSchema,
|
||||
])
|
||||
.nullish(),
|
||||
additional_kwargs: z.record(z.any()).optional(),
|
||||
})
|
||||
@@ -21,5 +53,7 @@ export const ChatMlMessageSchema = z
|
||||
content,
|
||||
json: Object.keys(other).length === 0 ? undefined : other,
|
||||
}));
|
||||
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
|
||||
|
||||
export const ChatMlArraySchema = z.array(ChatMlMessageSchema).min(1);
|
||||
export type ChatMlArraySchema = z.infer<typeof ChatMlArraySchema>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
|
||||
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -52,6 +53,15 @@ export const SessionPage: React.FC<{
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const commentCounts = api.comments.getCountByObjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
objectId: sessionId,
|
||||
objectType: "SESSION",
|
||||
},
|
||||
{ enabled: session.isSuccess },
|
||||
);
|
||||
|
||||
if (session.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this session." />;
|
||||
|
||||
@@ -87,6 +97,14 @@ export const SessionPage: React.FC<{
|
||||
}
|
||||
listKey="sessions"
|
||||
/>,
|
||||
<CommentDrawerButton
|
||||
key="comment"
|
||||
variant="outline"
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType="SESSION"
|
||||
count={commentCounts.data?.get(sessionId)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -178,7 +196,7 @@ const SessionIO = ({
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="col-span-2 flex flex-col gap-2 p-0">
|
||||
<div className="col-span-2 grid grid-flow-row gap-2 p-0">
|
||||
{!trace.data ? (
|
||||
<JsonSkeleton
|
||||
className="h-full w-full overflow-hidden px-2 py-1"
|
||||
|
||||
@@ -13,13 +13,13 @@ export function StarToggle({
|
||||
value,
|
||||
disabled = false,
|
||||
onClick,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
isLoading,
|
||||
}: {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (value: boolean) => Promise<unknown>;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -40,18 +40,19 @@ export function StarToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the trace table
|
||||
export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
tracesFilter: RouterInput["traces"]["all"];
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -89,7 +90,6 @@ export function StarTraceToggle({
|
||||
tracesFilter,
|
||||
(oldQueryData: RouterOutput["traces"]["all"] | undefined) => {
|
||||
return {
|
||||
totalCount: oldQueryData?.totalCount,
|
||||
traces: oldQueryData?.traces
|
||||
? oldQueryData.traces.map((trace) => {
|
||||
return {
|
||||
@@ -130,16 +130,17 @@ export function StarTraceToggle({
|
||||
);
|
||||
}
|
||||
|
||||
// use by the single trace view
|
||||
export function StarTraceDetailsToggle({
|
||||
projectId,
|
||||
traceId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
@@ -153,12 +154,15 @@ export function StarTraceDetailsToggle({
|
||||
onMutate: async () => {
|
||||
// Cancel any outgoing refetches
|
||||
// (so they don't overwrite our optimistic update)
|
||||
await utils.traces.byId.cancel();
|
||||
await utils.traces.byIdWithObservationsAndScores.cancel();
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Snapshot the previous value
|
||||
const prevData = utils.traces.byId.getData({ traceId, projectId });
|
||||
const prevData = utils.traces.byIdWithObservationsAndScores.getData({
|
||||
traceId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
return { prevData };
|
||||
},
|
||||
@@ -166,14 +170,21 @@ export function StarTraceDetailsToggle({
|
||||
setIsLoading(false);
|
||||
trpcErrorToast(err);
|
||||
// Rollback to the previous value if mutation fails
|
||||
utils.traces.byId.setData({ traceId, projectId }, context?.prevData);
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
context?.prevData,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false);
|
||||
|
||||
utils.traces.byId.setData(
|
||||
utils.traces.byIdWithObservationsAndScores.setData(
|
||||
{ traceId, projectId },
|
||||
(oldQueryData: RouterOutput["traces"]["byId"] | undefined) => {
|
||||
(
|
||||
oldQueryData:
|
||||
| RouterOutput["traces"]["byIdWithObservationsAndScores"]
|
||||
| undefined,
|
||||
) => {
|
||||
return oldQueryData
|
||||
? {
|
||||
...oldQueryData,
|
||||
@@ -182,7 +193,7 @@ export function StarTraceDetailsToggle({
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
void utils.traces.byId.invalidate();
|
||||
void utils.traces.byIdWithObservationsAndScores.invalidate();
|
||||
void utils.traces.all.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -212,12 +223,12 @@ export function StarSessionToggle({
|
||||
projectId,
|
||||
sessionId,
|
||||
value,
|
||||
size = "sm",
|
||||
size = "icon",
|
||||
}: {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
value: boolean;
|
||||
size?: "sm" | "xs";
|
||||
size?: "icon" | "icon-xs";
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const hasAccess = useHasProjectAccess({
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
@@ -26,6 +29,22 @@ export function DataTablePagination<TData>({
|
||||
paginationOptions = [10, 20, 30, 40, 50],
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const [inputState, setInputState] = useState<number | string>(currentPage);
|
||||
|
||||
useEffect(() => {
|
||||
setInputState(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const pageCount = table.getPageCount();
|
||||
const setPageIndex = table.setPageIndex;
|
||||
useEffect(() => {
|
||||
if (currentPage > pageCount && pageCount > 0) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [currentPage, pageCount, setPageIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
@@ -61,10 +80,60 @@ export function DataTablePagination<TData>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-center whitespace-nowrap text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{Math.max(table.getPageCount(), 1)}
|
||||
<div className="flex items-center justify-center gap-1 whitespace-nowrap text-sm font-medium">
|
||||
{table.getPageCount() !== -1 ? (
|
||||
<>
|
||||
Page
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pageCount}
|
||||
value={inputState} // Ensure the value is within bounds
|
||||
onChange={(e) => {
|
||||
setInputState(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const newValue = e.target.value;
|
||||
if (newValue === "") {
|
||||
table.setPageIndex(0);
|
||||
setInputState(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// if nan, reset to current page
|
||||
if (isNaN(Number(newValue))) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPageIndex = Number(newValue) - 1;
|
||||
if (newPageIndex < 0 || newPageIndex >= pageCount) {
|
||||
setInputState(currentPage);
|
||||
return;
|
||||
}
|
||||
|
||||
table.setPageIndex(newPageIndex);
|
||||
setInputState(newPageIndex + 1);
|
||||
}}
|
||||
className="h-8 appearance-none"
|
||||
style={{
|
||||
width: `${3 + Math.max(1, pageCount.toString().length)}ch`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
`Page ${currentPage}`
|
||||
)}
|
||||
{pageCount !== -1 ? (
|
||||
<span>of {pageCount}</span>
|
||||
) : (
|
||||
<span>
|
||||
of{" "}
|
||||
<LoaderCircle className="ml-1 inline-block h-3 w-3 animate-spin text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -112,12 +181,12 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
table.setPageIndex(pageCount - 1);
|
||||
capture("table:pagination_button_click", {
|
||||
type: "lastPage",
|
||||
});
|
||||
}}
|
||||
disabled={!table.getCanNextPage()}
|
||||
disabled={!table.getCanNextPage() || pageCount === -1}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
|
||||
@@ -31,7 +31,9 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
searchConfig?: SearchConfig;
|
||||
actionButtons?: React.ReactNode;
|
||||
filterState?: FilterState;
|
||||
setFilterState?: Dispatch<SetStateAction<FilterState>>;
|
||||
setFilterState?:
|
||||
| Dispatch<SetStateAction<FilterState>>
|
||||
| ((newState: FilterState) => void);
|
||||
columnVisibility?: VisibilityState;
|
||||
setColumnVisibility?: Dispatch<SetStateAction<VisibilityState>>;
|
||||
rowHeight?: RowHeight;
|
||||
@@ -79,7 +81,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="h-10 w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
pagination?: {
|
||||
pageCount: number;
|
||||
totalCount: number | null; // null if loading
|
||||
onChange: OnChangeFn<PaginationState>;
|
||||
state: PaginationState;
|
||||
options?: number[];
|
||||
@@ -90,7 +90,13 @@ export function DataTable<TData extends object, TValue>({
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: pagination !== undefined,
|
||||
pageCount: pagination?.pageCount ?? 0,
|
||||
pageCount:
|
||||
pagination?.totalCount === null ||
|
||||
pagination?.state.pageSize === undefined
|
||||
? -1
|
||||
: Math.ceil(
|
||||
Number(pagination?.totalCount) / pagination?.state.pageSize,
|
||||
),
|
||||
onPaginationChange: pagination?.onChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: onColumnVisibilityChange,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TableLink({
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded bg-primary-accent/20 px-2 py-0.5 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45",
|
||||
className,
|
||||
)}
|
||||
href={path}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -80,6 +81,7 @@ export type GenerationsTableRow = {
|
||||
promptId?: string;
|
||||
promptName?: string;
|
||||
promptVersion?: string;
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type GenerationsTableProps = {
|
||||
@@ -113,11 +115,12 @@ export default function GenerationsTable({
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [inputFilterState, setInputFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"generations",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
@@ -164,16 +167,26 @@ export default function GenerationsTable({
|
||||
...promptVersionFilter,
|
||||
]);
|
||||
|
||||
const generations = api.generations.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const getCountPayload = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
searchQuery,
|
||||
});
|
||||
page: 0,
|
||||
limit: 0,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const totalCount = generations.data?.totalCount ?? 0;
|
||||
const getAllPayload = {
|
||||
...getCountPayload,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
};
|
||||
|
||||
const generations = api.generations.all.useQuery(getAllPayload);
|
||||
const totalCountQuery = api.generations.countAll.useQuery(getCountPayload);
|
||||
|
||||
const totalCount = totalCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const startTimeFilter = filterState.find((f) => f.column === "Start Time");
|
||||
const filterOptions = api.generations.filterOptions.useQuery(
|
||||
@@ -653,6 +666,30 @@ export default function GenerationsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const traceTags: string[] | undefined = row.getValue("traceTags");
|
||||
console.log(traceTags);
|
||||
return (
|
||||
traceTags && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibilityState] =
|
||||
@@ -692,6 +729,7 @@ export default function GenerationsTable({
|
||||
promptId: generation.promptId ?? undefined,
|
||||
promptName: generation.promptName ?? undefined,
|
||||
promptVersion: generation.promptVersion ?? undefined,
|
||||
traceTags: generation.traceTags ?? undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -709,6 +747,7 @@ export default function GenerationsTable({
|
||||
updateQuery: setSearchQuery,
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
columnsWithCustomSelect={["model", "name", "traceName", "promptName"]}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibilityState}
|
||||
rowHeight={rowHeight}
|
||||
@@ -766,7 +805,7 @@ export default function GenerationsTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
});
|
||||
const totalCount = models.data?.totalCount ?? 0;
|
||||
const totalCount = models.data?.totalCount ?? null;
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
limit: paginationState.pageSize,
|
||||
});
|
||||
|
||||
const totalCount = configs.data?.totalCount ?? 0;
|
||||
const totalCount = configs.data?.totalCount ?? null;
|
||||
|
||||
const columns: LangfuseColumnDef<ScoreConfigTableRow>[] = [
|
||||
{
|
||||
@@ -272,7 +272,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export type ScoresTableRow = {
|
||||
id: string;
|
||||
@@ -43,6 +45,7 @@ export type ScoresTableRow = {
|
||||
traceName?: string;
|
||||
userId?: string;
|
||||
jobConfigurationId?: string;
|
||||
traceTags?: string[];
|
||||
};
|
||||
|
||||
export type ScoreFilterInput = Omit<
|
||||
@@ -90,11 +93,12 @@ export default function ScoresTable({
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("scores", "s");
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"scores",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
@@ -120,18 +124,32 @@ export default function ScoresTable({
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const scores = api.scores.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const getCountPayload = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
page: 0,
|
||||
limit: 1,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const getAllPayload = {
|
||||
...getCountPayload,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
const totalCount = scores.data?.totalCount ?? 0;
|
||||
};
|
||||
|
||||
const scores = api.scores.all.useQuery(getAllPayload);
|
||||
const totalScoreCountQuery = api.scores.countAll.useQuery(getCountPayload);
|
||||
const totalCount = totalScoreCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const filterOptions = api.scores.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
timestampFilter:
|
||||
dateRangeFilter[0]?.type === "datetime"
|
||||
? dateRangeFilter[0]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
@@ -323,6 +341,29 @@ export default function ScoresTable({
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const traceTags: string[] | undefined = row.getValue("traceTags");
|
||||
return (
|
||||
traceTags && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={traceTags} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns = rawColumns.filter(
|
||||
@@ -358,6 +399,7 @@ export default function ScoresTable({
|
||||
traceName: score.traceName ?? undefined,
|
||||
userId: score.traceUserId ?? undefined,
|
||||
jobConfigurationId: score.jobConfigurationId ?? undefined,
|
||||
traceTags: score.traceTags ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -401,7 +443,7 @@ export default function ScoresTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
@@ -23,20 +23,26 @@ import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import TagList from "@/src/features/tag/components/TagList";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export type SessionTableRow = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
userIds: string[];
|
||||
countTraces: number;
|
||||
bookmarked: boolean;
|
||||
sessionDuration: number | null;
|
||||
inputCost: Decimal;
|
||||
outputCost: Decimal;
|
||||
totalCost: Decimal;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
userIds: string[] | undefined;
|
||||
countTraces: number | undefined;
|
||||
sessionDuration: number | null | undefined;
|
||||
inputCost: Decimal | undefined;
|
||||
outputCost: Decimal | undefined;
|
||||
totalCost: Decimal | undefined;
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
totalTokens: number | undefined;
|
||||
traceTags: string[] | undefined;
|
||||
};
|
||||
|
||||
export type SessionTableProps = {
|
||||
@@ -52,11 +58,12 @@ export default function SessionsTable({
|
||||
}: SessionTableProps) {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"sessions",
|
||||
projectId,
|
||||
);
|
||||
|
||||
const userIdFilter: FilterState = userId
|
||||
@@ -88,22 +95,56 @@ export default function SessionsTable({
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("sessions", "s");
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "createdAt",
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const sessions = api.sessions.all.useQuery({
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const payloadCount = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
orderBy: null,
|
||||
page: 0,
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const payloadGetAll = {
|
||||
...payloadCount,
|
||||
orderBy: orderByState,
|
||||
});
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
};
|
||||
|
||||
const sessions = api.sessions.all.useQuery(payloadGetAll);
|
||||
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
|
||||
|
||||
const sessionMetrics = api.sessions.metrics.useQuery(
|
||||
{
|
||||
projectId,
|
||||
sessionIds: sessions.data?.sessions.map((s) => s.id) ?? [],
|
||||
},
|
||||
{
|
||||
enabled: sessions.data !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
type SessionCoreOutput = RouterOutput["sessions"]["all"]["sessions"][number];
|
||||
type SessionMetricOutput = RouterOutput["sessions"]["metrics"][number];
|
||||
|
||||
const sessionRowData = joinTableCoreAndMetrics<
|
||||
SessionCoreOutput,
|
||||
SessionMetricOutput
|
||||
>(sessions.data?.sessions, sessionMetrics.data);
|
||||
|
||||
const filterOptions = api.sessions.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
timestampFilter:
|
||||
dateRangeFilter[0]?.type === "datetime"
|
||||
? dateRangeFilter[0]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
@@ -114,36 +155,17 @@ export default function SessionsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const totalCount = sessions.data?.slice(1)[0]?.totalCount ?? 0;
|
||||
const totalCount = sessionCountQuery.data?.totalCount ?? null;
|
||||
useEffect(() => {
|
||||
if (sessions.isSuccess) {
|
||||
setDetailPageList(
|
||||
"sessions",
|
||||
sessions.data.map((t) => t.id),
|
||||
sessions.data.sessions.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessions.isSuccess, sessions.data]);
|
||||
|
||||
const convertToTableRow = (
|
||||
session: RouterOutput["sessions"]["all"][0],
|
||||
): SessionTableRow => {
|
||||
return {
|
||||
id: session.id,
|
||||
createdAt: session.createdAt.toLocaleString(),
|
||||
userIds: session.userIds,
|
||||
countTraces: session.countTraces,
|
||||
bookmarked: session.bookmarked,
|
||||
sessionDuration: session.sessionDuration,
|
||||
inputCost: session.inputCost,
|
||||
outputCost: session.outputCost,
|
||||
totalCost: session.totalCost,
|
||||
inputTokens: session.promptTokens,
|
||||
outputTokens: session.completionTokens,
|
||||
totalTokens: session.totalTokens,
|
||||
};
|
||||
};
|
||||
|
||||
const columns: LangfuseColumnDef<SessionTableRow>[] = [
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
@@ -151,8 +173,9 @@ export default function SessionsTable({
|
||||
header: undefined,
|
||||
size: 50,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked = row.getValue("bookmarked");
|
||||
const sessionId = row.getValue("id");
|
||||
const bookmarked: SessionTableRow["bookmarked"] =
|
||||
row.getValue("bookmarked");
|
||||
const sessionId: SessionTableRow["id"] = row.getValue("id");
|
||||
|
||||
return typeof sessionId === "string" &&
|
||||
typeof bookmarked === "boolean" ? (
|
||||
@@ -160,7 +183,7 @@ export default function SessionsTable({
|
||||
sessionId={sessionId}
|
||||
projectId={projectId}
|
||||
value={bookmarked}
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -172,7 +195,7 @@ export default function SessionsTable({
|
||||
header: "ID",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("id");
|
||||
const value: SessionTableRow["id"] = row.getValue("id");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
@@ -197,7 +220,11 @@ export default function SessionsTable({
|
||||
size: 130,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("sessionDuration");
|
||||
const value: SessionTableRow["sessionDuration"] =
|
||||
row.getValue("sessionDuration");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value && typeof value === "number"
|
||||
? formatIntervalSeconds(value)
|
||||
: undefined;
|
||||
@@ -212,7 +239,10 @@ export default function SessionsTable({
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userIds");
|
||||
const value: SessionTableRow["userIds"] = row.getValue("userIds");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value && Array.isArray(value) ? (
|
||||
<div className="flex gap-1">
|
||||
{(value as string[]).map((user) => (
|
||||
@@ -236,6 +266,14 @@ export default function SessionsTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: SessionTableRow["countTraces"] =
|
||||
row.getValue("countTraces");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? <span>{numberFormatter(value, 0)}</span> : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
@@ -246,7 +284,10 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("inputCost");
|
||||
const value: SessionTableRow["inputCost"] = row.getValue("inputCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -261,8 +302,10 @@ export default function SessionsTable({
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("outputCost");
|
||||
|
||||
const value: SessionTableRow["outputCost"] = row.getValue("outputCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -276,8 +319,10 @@ export default function SessionsTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | null | undefined = row.getValue("totalCost");
|
||||
|
||||
const value: SessionTableRow["totalCost"] = row.getValue("totalCost");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{usdFormatter(value.toNumber())}</span>
|
||||
) : undefined;
|
||||
@@ -292,8 +337,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: number | undefined = row.getValue("inputTokens");
|
||||
|
||||
const value: SessionTableRow["inputTokens"] =
|
||||
row.getValue("inputTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -308,8 +356,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("outputTokens");
|
||||
|
||||
const value: SessionTableRow["outputTokens"] =
|
||||
row.getValue("outputTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -324,7 +375,11 @@ export default function SessionsTable({
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("totalTokens");
|
||||
const value: SessionTableRow["totalTokens"] =
|
||||
row.getValue("totalTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return value ? (
|
||||
<span>{numberFormatter(Number(value), 0)}</span>
|
||||
) : undefined;
|
||||
@@ -338,9 +393,15 @@ export default function SessionsTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const promptTokens = row.getValue("inputTokens");
|
||||
const completionTokens = row.getValue("outputTokens");
|
||||
const totalTokens = row.getValue("totalTokens");
|
||||
const promptTokens: SessionTableRow["inputTokens"] =
|
||||
row.getValue("inputTokens");
|
||||
const completionTokens: SessionTableRow["outputTokens"] =
|
||||
row.getValue("outputTokens");
|
||||
const totalTokens: SessionTableRow["totalTokens"] =
|
||||
row.getValue("totalTokens");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return (
|
||||
<TokenUsageBadge
|
||||
promptTokens={Number(promptTokens)}
|
||||
@@ -351,6 +412,32 @@ export default function SessionsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "traceTags",
|
||||
id: "traceTags",
|
||||
header: "Trace Tags",
|
||||
size: 250,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: SessionTableRow["traceTags"] = row.getValue("traceTags");
|
||||
if (!sessionMetrics.isSuccess) {
|
||||
return <Skeleton className="h-3 w-1/2" />;
|
||||
}
|
||||
return (
|
||||
value && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-x-2 gap-y-1",
|
||||
rowHeight !== "s" && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
<TagList selectedTags={value} isLoading={false} viewOnly />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const transformFilterOptions = () => {
|
||||
@@ -381,6 +468,8 @@ export default function SessionsTable({
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
columnsWithCustomSelect={["userIds"]}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -396,11 +485,25 @@ export default function SessionsTable({
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: sessions.data.map((t) => convertToTableRow(t)),
|
||||
data: sessionRowData.rows?.map((session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt.toLocaleString(),
|
||||
userIds: session.userIds,
|
||||
countTraces: session.countTraces,
|
||||
bookmarked: session.bookmarked,
|
||||
sessionDuration: session.sessionDuration,
|
||||
inputCost: session.inputCost,
|
||||
outputCost: session.outputCost,
|
||||
totalCost: session.totalCost,
|
||||
inputTokens: session.promptTokens,
|
||||
outputTokens: session.completionTokens,
|
||||
totalTokens: session.totalTokens,
|
||||
traceTags: session.traceTags,
|
||||
})),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
@@ -413,6 +516,7 @@ export default function SessionsTable({
|
||||
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
|
||||
href: "https://langfuse.com/docs/tracing-features/sessions",
|
||||
}}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { type RouterInput } from "@/src/utils/types";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import { type RowSelectionState } from "@tanstack/react-table";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -44,6 +44,8 @@ import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { type ScoreAggregate } from "@/src/features/scores/lib/types";
|
||||
import { useIndividualScoreColumns } from "@/src/features/scores/hooks/useIndividualScoreColumns";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -51,10 +53,10 @@ export type TracesTableRow = {
|
||||
timestamp: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
level: ObservationLevel;
|
||||
observationCount: number;
|
||||
level?: ObservationLevel;
|
||||
observationCount?: number;
|
||||
// scores holds grouped column with individual scores
|
||||
scores: ScoreAggregate;
|
||||
scores?: ScoreAggregate;
|
||||
latency?: number;
|
||||
release?: string;
|
||||
version?: string;
|
||||
@@ -65,9 +67,9 @@ export type TracesTableRow = {
|
||||
metadata?: unknown;
|
||||
tags: string[];
|
||||
usage: {
|
||||
promptTokens: bigint;
|
||||
completionTokens: bigint;
|
||||
totalTokens: bigint;
|
||||
promptTokens?: bigint;
|
||||
completionTokens?: bigint;
|
||||
totalTokens?: bigint;
|
||||
};
|
||||
inputCost?: Decimal;
|
||||
outputCost?: Decimal;
|
||||
@@ -96,10 +98,11 @@ export default function TracesTable({
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
useTableDateRange(projectId);
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[],
|
||||
"traces",
|
||||
projectId,
|
||||
);
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "timestamp",
|
||||
@@ -133,17 +136,44 @@ export default function TracesTable({
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const tracesAllQueryFilter = {
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
const tracesAllCountFilter = {
|
||||
projectId,
|
||||
filter: filterState,
|
||||
searchQuery,
|
||||
// "empty" values as they do not matter for total count
|
||||
page: 0,
|
||||
limit: 0,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const tracesAllQueryFilter = {
|
||||
...tracesAllCountFilter,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
};
|
||||
const traces = api.traces.all.useQuery(tracesAllQueryFilter);
|
||||
const totalCountQuery = api.traces.countAll.useQuery(tracesAllCountFilter);
|
||||
const traceMetrics = api.traces.metrics.useQuery(
|
||||
{
|
||||
projectId,
|
||||
traceIds: traces.data?.traces.map((t) => t.id) ?? [],
|
||||
},
|
||||
{
|
||||
enabled: traces.data !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
type TracesCoreOutput = RouterOutput["traces"]["all"]["traces"][number];
|
||||
type TraceMetricOutput = RouterOutput["traces"]["metrics"][number];
|
||||
|
||||
const traceRowData = joinTableCoreAndMetrics<
|
||||
TracesCoreOutput,
|
||||
TraceMetricOutput
|
||||
>(traces.data?.traces, traceMetrics.data);
|
||||
|
||||
const totalCount = totalCountQuery.data?.totalCount ?? null;
|
||||
|
||||
const totalCount = traces.data?.totalCount ?? 0;
|
||||
useEffect(() => {
|
||||
if (traces.isSuccess) {
|
||||
setDetailPageList(
|
||||
@@ -188,6 +218,7 @@ export default function TracesTable({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedFilterOption: selectedOption,
|
||||
cellsLoading: !traceMetrics.data,
|
||||
});
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
@@ -239,7 +270,7 @@ export default function TracesTable({
|
||||
traceId={traceId}
|
||||
projectId={projectId}
|
||||
value={bookmarked}
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -328,6 +359,7 @@ export default function TracesTable({
|
||||
// add seconds to the end of the latency
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["latency"] = row.getValue("latency");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return value !== undefined ? formatIntervalSeconds(value) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -340,6 +372,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -353,6 +386,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -366,6 +400,7 @@ export default function TracesTable({
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -379,11 +414,12 @@ export default function TracesTable({
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<TokenUsageBadge
|
||||
promptTokens={value.promptTokens}
|
||||
completionTokens={value.completionTokens}
|
||||
totalTokens={value.totalTokens}
|
||||
promptTokens={value.promptTokens ?? 0}
|
||||
completionTokens={value.completionTokens ?? 0}
|
||||
totalTokens={value.totalTokens ?? 0}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
@@ -391,7 +427,10 @@ export default function TracesTable({
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
|
||||
{
|
||||
...getScoreGroupColumnProps(isColumnLoading || !traceMetrics.data),
|
||||
columns: scoreColumns,
|
||||
},
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
@@ -399,6 +438,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -420,6 +460,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -441,6 +482,7 @@ export default function TracesTable({
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return (
|
||||
<div>
|
||||
{cost ? (
|
||||
@@ -521,7 +563,8 @@ export default function TracesTable({
|
||||
size: 75,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["level"] = row.getValue("level");
|
||||
return (
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return value ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-sm p-0.5 text-xs",
|
||||
@@ -531,6 +574,8 @@ export default function TracesTable({
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -547,6 +592,12 @@ export default function TracesTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["observationCount"] =
|
||||
row.getValue("observationCount");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return <span>{value}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
@@ -629,7 +680,7 @@ export default function TracesTable({
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return traces.isSuccess
|
||||
? traces.data.traces.map((trace) => {
|
||||
? traceRowData?.rows?.map((trace) => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
id: trace.id,
|
||||
@@ -648,17 +699,19 @@ export default function TracesTable({
|
||||
completionTokens: trace.completionTokens,
|
||||
totalTokens: trace.totalTokens,
|
||||
},
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
trace.scores,
|
||||
),
|
||||
scores: trace.scores
|
||||
? verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
trace.scores,
|
||||
)
|
||||
: undefined,
|
||||
inputCost: trace.calculatedInputCost ?? undefined,
|
||||
outputCost: trace.calculatedOutputCost ?? undefined,
|
||||
totalCost: trace.calculatedTotalCost ?? undefined,
|
||||
};
|
||||
})
|
||||
}) ?? []
|
||||
: [];
|
||||
}, [traces, scoreKeysAndProps]);
|
||||
}, [traces, traceRowData, scoreKeysAndProps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -672,6 +725,7 @@ export default function TracesTable({
|
||||
}}
|
||||
filterState={userFilterState}
|
||||
setFilterState={useDebounce(setUserFilterState)}
|
||||
columnsWithCustomSelect={["name", "tags"]}
|
||||
actionButtons={
|
||||
Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
@@ -713,7 +767,7 @@ export default function TracesTable({
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(Number(totalCount) / paginationState.pageSize),
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user