Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9377ea4e | ||
|
|
9f3ac3ab9f | ||
|
|
e38deed204 | ||
|
|
19bf90dd71 | ||
|
|
0523fbfc67 | ||
|
|
0fcdac0cf1 | ||
|
|
c2bc17d4d1 | ||
|
|
7d11812fd1 | ||
|
|
877bad2234 | ||
|
|
dd90a28d1c | ||
|
|
c8c9fc0eba | ||
|
|
aafd45f771 | ||
|
|
fc6f32ebe2 | ||
|
|
765406492a | ||
|
|
3cd840766b | ||
|
|
ed4d7ceb40 | ||
|
|
3520b1d28d | ||
|
|
1f0e7b2c37 | ||
|
|
17c9fd0028 | ||
|
|
ab997018ff | ||
|
|
bc10176935 | ||
|
|
6058fc7388 | ||
|
|
923ea17fef | ||
|
|
6b3630f3c2 | ||
|
|
c4ad55b3ff | ||
|
|
45c17ca286 | ||
|
|
d1298e68a2 | ||
|
|
ab85bad70b | ||
|
|
042901ddc8 | ||
|
|
111240c2d6 | ||
|
|
6b2c14acbb | ||
|
|
37e72a4b7f | ||
|
|
ad93b6c3c5 | ||
|
|
d0e33c1c34 | ||
|
|
f02d4916a4 | ||
|
|
c6151dd042 | ||
|
|
dd34aad84d | ||
|
|
78ca9757ff | ||
|
|
805a91c9f8 | ||
|
|
24ec7884a1 | ||
|
|
18122fa334 | ||
|
|
c564608ee7 | ||
|
|
a5413c101b | ||
|
|
25f6c6966e | ||
|
|
634057e32b | ||
|
|
ff8cae0c06 | ||
|
|
8c1af8a046 | ||
|
|
d03e035d5c | ||
|
|
0a83be775f | ||
|
|
f9a5c23e9b | ||
|
|
fc0fb4a559 | ||
|
|
e0268f38bb | ||
|
|
a729306b5b | ||
|
|
85d92daafd | ||
|
|
939b233120 | ||
|
|
5612aa2d27 | ||
|
|
2a72fcc421 | ||
|
|
99a45ccd97 | ||
|
|
b123cc5a4f | ||
|
|
a3e5208a90 | ||
|
|
484ef3fcac | ||
|
|
e080690408 | ||
|
|
bd53c54435 | ||
|
|
804255b7ec | ||
|
|
0fa4b381fc | ||
|
|
01102b0128 | ||
|
|
ddd7671d5b | ||
|
|
1ff58377f1 | ||
|
|
41ab5e0b7b | ||
|
|
a78bf26bd4 | ||
|
|
a185863bc5 | ||
|
|
57b46b9f19 | ||
|
|
58b547e0f1 | ||
|
|
6554c5ee88 | ||
|
|
90fc9fb660 | ||
|
|
e2c5c9b5cc | ||
|
|
c27c32f844 | ||
|
|
d33066e012 | ||
|
|
dff8ab4faa | ||
|
|
57a42c8c4b | ||
|
|
a8258382d5 | ||
|
|
ba9d5217a4 | ||
|
|
69bf783b11 | ||
|
|
31fb4b4b20 | ||
|
|
2d2c3c5818 |
+8
-1
@@ -6,6 +6,12 @@
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
|
||||
CLICKHOUSE_URL="http://localhost:8123"
|
||||
CLICKHOUSE_USER="clickhouse"
|
||||
CLICKHOUSE_PASSWORD="clickhouse"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
@@ -44,4 +50,5 @@ REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=mybasicauthsecret
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=6c16874e5c0f0cc74ddec00425fa99fbe9ffbe412b7d5d906a4b00005df91403
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
|
||||
+4
-1
@@ -18,4 +18,7 @@ REDIS_HOST="127.0.0.1"
|
||||
REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
LANGFUSE_WORKER_PASSWORD=myworkerpassword
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
+41
-11
@@ -16,14 +16,17 @@ DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# You can generate a new secret on the command line with:
|
||||
# For each of these, you can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
NEXTAUTH_SECRET="secret"
|
||||
SALT="salt"
|
||||
NEXTAUTH_SECRET="secret" # https://next-auth.js.org/configuration/options#secret
|
||||
SALT="salt" # salt used to hash api keys
|
||||
|
||||
# API level encryption for sensitive data
|
||||
# Must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32
|
||||
ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
# Use CSP headers to enforce HTTPS, optional
|
||||
LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
|
||||
|
||||
# Docker only, optional
|
||||
@@ -44,6 +47,7 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
# AUTH_DISABLE_SIGNUP=true
|
||||
# AUTH_SESSION_MAX_AGE=43200 # 30 days in minutes (default)
|
||||
|
||||
# SSO, each group is optional
|
||||
# AUTH_GOOGLE_CLIENT_ID=
|
||||
@@ -104,9 +108,6 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# Disable the expensive analytics queries and related features
|
||||
# LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES="true"
|
||||
|
||||
# Customize the default datetime offset filter used in for the UI tables
|
||||
# LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET="7" # int (days)
|
||||
|
||||
### END Envs to be deprecated in Langfuse v3.0
|
||||
|
||||
|
||||
@@ -145,11 +146,40 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# Crisp chat
|
||||
# NEXT_PUBLIC_CRISP_WEBSITE_ID=
|
||||
|
||||
# Encryption
|
||||
# ENCRYPTION_KEY=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
## START Langfuse V3 Ingestion
|
||||
|
||||
# Clickhouse
|
||||
# CLICKHOUSE_MIGRATION_URL=
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_BUFFER_TTL_SECONDS=
|
||||
# LANGFUSE_INGESTION_FLUSH_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_FLUSH_ATTEMPTS=
|
||||
# LANGFUSE_INGESTION_FLUSH_PROCESSING_CONCURRENCY=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
# LANGFUSE_LOG_LEVEL=
|
||||
|
||||
## END Langfuse V3 Ingestion
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
fi
|
||||
|
||||
tests-web:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-web (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
grep -v '^REDIS_HOST=' .env.dev.example > web/.env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -118,6 +119,7 @@ jobs:
|
||||
run: pnpm --filter=web run test
|
||||
|
||||
tests-worker:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
@@ -145,10 +147,17 @@ jobs:
|
||||
run: |
|
||||
pnpm install
|
||||
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
cp .env.dev.example web/.env
|
||||
cp .env.dev.example worker/.env
|
||||
|
||||
- name: Run + migrate
|
||||
run: |
|
||||
@@ -158,6 +167,7 @@ jobs:
|
||||
|
||||
- name: Seed DB
|
||||
run: |
|
||||
pnpm run --filter=shared ch:up
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
name: Deploy to worker (US)
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "4038"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: 260cd04e-5dc8-4b22-a291-ad4453f981f7
|
||||
PORTER_HOST: https://dashboard.getporter.dev
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_4038 }}
|
||||
@@ -127,7 +127,6 @@ Packages that integrate with Langfuse:
|
||||
| [AI SDK by Vercel](/docs/sdk/typescript/example-vercel-ai) | Typescript SDK that makes streaming LLM outputs super easy. |
|
||||
| [Flowise](/docs/integrations/flowise) | JS/TS no-code builder for customized LLM flows. |
|
||||
| [Langflow](/docs/integrations/langflow) | Python-based UI for LangChain, designed with react-flow to provide an effortless way to experiment and prototype flows. |
|
||||
| [Superagent](/docs/integrations/superagent) | Open Source AI Assistant Framework & API for prototyping and deployment of agents. |
|
||||
|
||||
## Questions and feedback
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
+25
-3
@@ -1,4 +1,22 @@
|
||||
services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server
|
||||
user: "101:101"
|
||||
container_name: clickhouse
|
||||
hostname: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
redis:
|
||||
image: redis:7.2.4
|
||||
restart: always
|
||||
@@ -7,7 +25,7 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
db:
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION:-latest}
|
||||
restart: always
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
@@ -18,8 +36,12 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
langfuse_postgres_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_data:
|
||||
driver: local
|
||||
langfuse_clickhouse_logs:
|
||||
driver: local
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { env as sharedEnv } from "@langfuse/shared";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
|
||||
ADMIN_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = { ...sharedEnv, ...EnvSchema.parse(process.env) };
|
||||
|
||||
@@ -5,16 +5,36 @@ types:
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of a trace
|
||||
timestamp: datetime
|
||||
name: optional<string>
|
||||
input: optional<unknown>
|
||||
output: optional<unknown>
|
||||
sessionId: optional<string>
|
||||
release: optional<string>
|
||||
version: optional<string>
|
||||
userId: optional<string>
|
||||
metadata: optional<unknown>
|
||||
tags: optional<list<string>>
|
||||
timestamp:
|
||||
type: datetime
|
||||
docs: The timestamp when the trace was created
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the trace
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the trace. Can be any JSON.
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the trace. Can be any JSON.
|
||||
sessionId:
|
||||
type: optional<string>
|
||||
docs: The session identifier associated with the trace
|
||||
release:
|
||||
type: optional<string>
|
||||
docs: The release version of the application when the trace was created
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: The user identifier associated with the trace
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: The metadata associated with the trace. Can be any JSON.
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: The tags associated with the trace. Can be an array of strings or null.
|
||||
public:
|
||||
type: optional<boolean>
|
||||
docs: Public traces are accessible via url without login
|
||||
@@ -65,37 +85,97 @@ types:
|
||||
traces: list<Trace>
|
||||
Observation:
|
||||
properties:
|
||||
id: string
|
||||
traceId: optional<string>
|
||||
type: string
|
||||
name: optional<string>
|
||||
startTime: datetime
|
||||
endTime: optional<datetime>
|
||||
completionStartTime: optional<datetime>
|
||||
model: optional<string>
|
||||
modelParameters: optional<map<string, MapValue>>
|
||||
input: optional<unknown>
|
||||
version: optional<string>
|
||||
metadata: optional<unknown>
|
||||
output: optional<unknown>
|
||||
usage: optional<Usage>
|
||||
level: ObservationLevel
|
||||
statusMessage: optional<string>
|
||||
parentObservationId: optional<string>
|
||||
promptId: optional<string>
|
||||
id:
|
||||
type: string
|
||||
docs: The unique identifier of the observation
|
||||
traceId:
|
||||
type: optional<string>
|
||||
docs: The trace ID associated with the observation
|
||||
type:
|
||||
type: string
|
||||
docs: The type of the observation
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the observation
|
||||
startTime:
|
||||
type: datetime
|
||||
docs: The start time of the observation
|
||||
endTime:
|
||||
type: optional<datetime>
|
||||
docs: The end time of the observation.
|
||||
completionStartTime:
|
||||
type: optional<datetime>
|
||||
docs: The completion start time of the observation
|
||||
model:
|
||||
type: optional<string>
|
||||
docs: The model used for the observation
|
||||
modelParameters:
|
||||
type: optional<map<string, MapValue>>
|
||||
docs: The parameters of the model used for the observation
|
||||
input:
|
||||
type: optional<unknown>
|
||||
docs: The input data of the observation
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: The version of the observation
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Additional metadata of the observation
|
||||
output:
|
||||
type: optional<unknown>
|
||||
docs: The output data of the observation
|
||||
usage:
|
||||
type: optional<Usage>
|
||||
docs: The usage data of the observation
|
||||
level:
|
||||
type: ObservationLevel
|
||||
docs: The level of the observation
|
||||
statusMessage:
|
||||
type: optional<string>
|
||||
docs: The status message of the observation
|
||||
parentObservationId:
|
||||
type: optional<string>
|
||||
docs: The parent observation ID
|
||||
promptId:
|
||||
type: optional<string>
|
||||
docs: The prompt ID associated with the observation
|
||||
|
||||
ObservationsView:
|
||||
extends: Observation
|
||||
properties:
|
||||
modelId: optional<string>
|
||||
inputPrice: optional<double>
|
||||
outputPrice: optional<double>
|
||||
totalPrice: optional<double>
|
||||
calculatedInputCost: optional<double>
|
||||
calculatedOutputCost: optional<double>
|
||||
calculatedTotalCost: optional<double>
|
||||
latency: optional<double>
|
||||
timeToFirstToken: optional<double>
|
||||
promptName:
|
||||
type: optional<string>
|
||||
docs: The name of the prompt associated with the observation
|
||||
promptVersion:
|
||||
type: optional<integer>
|
||||
docs: The version of the prompt associated with the observation
|
||||
modelId:
|
||||
type: optional<string>
|
||||
docs: The unique identifier of the model
|
||||
inputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the input in USD
|
||||
outputPrice:
|
||||
type: optional<double>
|
||||
docs: The price of the output in USD.
|
||||
totalPrice:
|
||||
type: optional<double>
|
||||
docs: The total price in USD.
|
||||
calculatedInputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the input in USD
|
||||
calculatedOutputCost:
|
||||
type: optional<double>
|
||||
docs: The calculated cost of the output in USD
|
||||
calculatedTotalCost:
|
||||
type: optional<double>
|
||||
docs: The calculated total cost in USD
|
||||
latency:
|
||||
type: optional<double>
|
||||
docs: The latency in seconds.
|
||||
timeToFirstToken:
|
||||
type: optional<double>
|
||||
docs: The time to the first token in seconds
|
||||
|
||||
Usage:
|
||||
docs: Standard interface for usage and cost
|
||||
@@ -237,14 +317,30 @@ types:
|
||||
updatedAt: datetime
|
||||
DatasetRun:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
description: optional<string>
|
||||
metadata: optional<unknown>
|
||||
datasetId: string
|
||||
datasetName: string
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
id:
|
||||
type: string
|
||||
docs: Unique identifier of the dataset run
|
||||
name:
|
||||
type: string
|
||||
docs: Name of the dataset run
|
||||
description:
|
||||
type: optional<string>
|
||||
docs: Description of the run
|
||||
metadata:
|
||||
type: optional<unknown>
|
||||
docs: Metadata of the dataset run
|
||||
datasetId:
|
||||
type: string
|
||||
docs: Id of the associated dataset
|
||||
datasetName:
|
||||
type: string
|
||||
docs: Name of the associated dataset
|
||||
createdAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was created
|
||||
updatedAt:
|
||||
type: datetime
|
||||
docs: The date and time when the dataset run was last updated
|
||||
DatasetRunWithItems:
|
||||
extends: DatasetRun
|
||||
properties:
|
||||
|
||||
@@ -49,7 +49,7 @@ types:
|
||||
sourceObservationId: optional<string>
|
||||
id:
|
||||
type: optional<string>
|
||||
docs: Dataset items are upserted on their id. Id needs to be globally unique and cannot be reused across datasets.
|
||||
docs: Dataset items are upserted on their id. Id needs to be unique (project-level) and cannot be reused across datasets.
|
||||
status:
|
||||
type: optional<commons.DatasetStatus>
|
||||
docs: Defaults to ACTIVE for newly created items
|
||||
|
||||
@@ -39,6 +39,9 @@ service:
|
||||
toStartTime:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include observations with a certain version.
|
||||
response: ObservationsViews
|
||||
|
||||
types:
|
||||
|
||||
@@ -41,6 +41,12 @@ service:
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
fromUpdatedAt:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)
|
||||
toUpdatedAt:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)
|
||||
|
||||
response: PromptMetaListResponse
|
||||
|
||||
@@ -63,6 +69,10 @@ types:
|
||||
versions: list<integer>
|
||||
labels: list<string>
|
||||
tags: list<string>
|
||||
lastUpdatedAt: datetime
|
||||
lastConfig:
|
||||
type: unknown
|
||||
docs: Config object of the most recent prompt version that matches the filters (if any are provided)
|
||||
|
||||
CreatePromptRequest:
|
||||
union:
|
||||
|
||||
@@ -7,7 +7,7 @@ service:
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
list:
|
||||
docs: Get sessions.
|
||||
docs: Get sessions
|
||||
method: GET
|
||||
path: /sessions
|
||||
request:
|
||||
|
||||
@@ -16,7 +16,7 @@ service:
|
||||
docs: The unique langfuse identifier of a trace
|
||||
response: commons.TraceWithFullDetails
|
||||
list:
|
||||
docs: Get list of traces.
|
||||
docs: Get list of traces
|
||||
method: GET
|
||||
path: /traces
|
||||
request:
|
||||
@@ -44,6 +44,12 @@ service:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Only traces that include all of these tags will be returned.
|
||||
version:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include traces with a certain version.
|
||||
release:
|
||||
type: optional<string>
|
||||
docs: Optional filter to only include traces with a certain release.
|
||||
response: Traces
|
||||
|
||||
types:
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.65.0",
|
||||
"version": "2.69.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -16,8 +16,8 @@
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:seed:examples": "turbo run db:seed:examples",
|
||||
"nuke": "bash ./scripts/nuke.sh",
|
||||
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"dx-f": "pnpm i && pnpm run infra:dev:up && pnpm --filter=shared run db:reset -f && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev": "turbo run dev",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE traces;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE traces (
|
||||
`id` String,
|
||||
`timestamp` DateTime64(3),
|
||||
`name` String,
|
||||
`user_id` Nullable(String),
|
||||
`metadata` Map(String, String) CODEC(ZSTD(1)),
|
||||
`release` Nullable(String),
|
||||
`version` Nullable(String),
|
||||
`project_id` String,
|
||||
`public` Bool,
|
||||
`bookmarked` Bool,
|
||||
`tags` Array(String),
|
||||
`input` Nullable(String) CODEC(ZSTD(1)),
|
||||
`output` Nullable(String) CODEC(ZSTD(1)),
|
||||
`session_id` Nullable(String),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1,
|
||||
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
toUnixTimestamp(timestamp),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE observations;
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE observations (
|
||||
`id` String,
|
||||
`trace_id` String,
|
||||
`project_id` String,
|
||||
`type` LowCardinality(String),
|
||||
`parent_observation_id` Nullable(String),
|
||||
`start_time` DateTime64(3),
|
||||
`end_time` Nullable(DateTime64(3)),
|
||||
`name` String,
|
||||
`metadata` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
|
||||
`level` LowCardinality(String),
|
||||
`status_message` Nullable(String),
|
||||
`version` Nullable(String),
|
||||
`input` Nullable(String) CODEC(ZSTD(3)),
|
||||
`output` Nullable(String) CODEC(ZSTD(3)),
|
||||
`provided_model_name` Nullable(String),
|
||||
`internal_model_id` Nullable(String),
|
||||
`model_parameters` Nullable(String),
|
||||
`provided_input_usage_units` Nullable(Decimal64(12)),
|
||||
`provided_output_usage_units` Nullable(Decimal64(12)),
|
||||
`provided_total_usage_units` Nullable(Decimal64(12)),
|
||||
`input_usage_units` Nullable(Decimal64(12)),
|
||||
`output_usage_units` Nullable(Decimal64(12)),
|
||||
`total_usage_units` Nullable(Decimal64(12)),
|
||||
`unit` Nullable(String),
|
||||
`provided_input_cost` Nullable(Decimal64(12)),
|
||||
`provided_output_cost` Nullable(Decimal64(12)),
|
||||
`provided_total_cost` Nullable(Decimal64(12)),
|
||||
`input_cost` Nullable(Decimal64(12)),
|
||||
`output_cost` Nullable(Decimal64(12)),
|
||||
`total_cost` Nullable(Decimal64(12)),
|
||||
`completion_start_time` Nullable(DateTime64(3)),
|
||||
`prompt_id` Nullable(String),
|
||||
`prompt_name` Nullable(String),
|
||||
`prompt_version` Nullable(UInt16),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_trace_id trace_id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_project_id project_id TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_res_metadata_key mapKeys(metadata) TYPE bloom_filter() GRANULARITY 1,
|
||||
INDEX idx_res_metadata_value mapValues(metadata) TYPE bloom_filter() GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(start_time)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
`type`,
|
||||
trace_id,
|
||||
toUnixTimestamp(start_time),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE scores;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE scores (
|
||||
`id` String,
|
||||
`timestamp` DateTime64(3),
|
||||
`project_id` String,
|
||||
`trace_id` String,
|
||||
`observation_id` Nullable(String),
|
||||
`name` String,
|
||||
`value` Float64,
|
||||
`source` String,
|
||||
`comment` Nullable(String) CODEC(ZSTD(1)),
|
||||
`author_user_id` Nullable(String),
|
||||
`config_id` Nullable(String),
|
||||
`data_type` String,
|
||||
`string_value` Nullable(String),
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
INDEX idx_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_project_id trace_id TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) ENGINE = ReplacingMergeTree Partition by toYYYYMM(timestamp)
|
||||
ORDER BY (
|
||||
project_id,
|
||||
trace_id,
|
||||
toUnixTimestamp(timestamp),
|
||||
id
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
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 down command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" down
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
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 drop command
|
||||
migrate -source file://clickhouse/migrations -database "$DATABASE_URL" drop
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
source ../../.env
|
||||
|
||||
# Check if golang-migrate is installed
|
||||
if ! command -v migrate &> /dev/null
|
||||
then
|
||||
echo "Error: golang-migrate is not installed or not in PATH."
|
||||
echo "Please install golang-migrate via 'brew install golang-migrate' to run this script."
|
||||
echo "Visit https://github.com/golang-migrate/migrate for more installation instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the database URL
|
||||
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
|
||||
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
|
||||
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
|
||||
@@ -38,15 +38,22 @@
|
||||
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
|
||||
"db:generate": "dotenv -e ../../.env -- npx prisma generate",
|
||||
"db:seed:examples": "dotenv -e ../../.env -- npx prisma db seed -- --environment examples",
|
||||
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load"
|
||||
"db:seed:load": "dotenv -e ../../.env -- npx prisma db seed -- --environment load",
|
||||
"ch:status": "dotenv -e ../../.env -- goose -dir './clickhouse/migrations/' status",
|
||||
"ch:up": "bash clickhouse/scripts/up.sh",
|
||||
"ch:down": "bash clickhouse/scripts/down.sh",
|
||||
"ch:drop": "bash clickhouse/scripts/drop.sh",
|
||||
"ch:reset": "pnpm run ch:down && pnpm run ch:up"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node -r tsconfig-paths/register -r dotenv/config --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@aws-sdk/client-s3": "^3.550.0",
|
||||
"@aws-sdk/lib-storage": "^3.568.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.554.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
@@ -55,12 +62,16 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
"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",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.22.5"
|
||||
},
|
||||
|
||||
@@ -121,15 +121,16 @@ export type CronJobs = {
|
||||
};
|
||||
export type Dataset = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
project_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type DatasetItem = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
status: Generated<DatasetStatus>;
|
||||
input: unknown | null;
|
||||
expected_output: unknown | null;
|
||||
@@ -142,6 +143,7 @@ export type DatasetItem = {
|
||||
};
|
||||
export type DatasetRunItems = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
dataset_run_id: string;
|
||||
dataset_item_id: string;
|
||||
trace_id: string;
|
||||
@@ -151,6 +153,7 @@ export type DatasetRunItems = {
|
||||
};
|
||||
export type DatasetRuns = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
@@ -306,6 +309,8 @@ export type ObservationView = {
|
||||
unit: string | null;
|
||||
completion_start_time: Timestamp | null;
|
||||
prompt_id: string | null;
|
||||
prompt_name: string | null;
|
||||
prompt_version: number | null;
|
||||
model_id: string | null;
|
||||
input_price: string | null;
|
||||
output_price: string | null;
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
-- Add project_id to datasets, dataset_items, dataset_runs, dataset_run_items
|
||||
ALTER TABLE "dataset_items"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
ALTER TABLE "dataset_run_items"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
ADD COLUMN "project_id" TEXT;
|
||||
|
||||
-- Backfill project_id for dataset_items
|
||||
UPDATE dataset_items
|
||||
SET project_id = datasets.project_id
|
||||
FROM datasets
|
||||
WHERE dataset_items.dataset_id = datasets.id;
|
||||
|
||||
-- Backfill project_id for dataset_runs
|
||||
UPDATE dataset_runs
|
||||
SET project_id = datasets.project_id
|
||||
FROM datasets
|
||||
WHERE dataset_runs.dataset_id = datasets.id;
|
||||
|
||||
-- Backfill project_id for dataset_run_items
|
||||
UPDATE dataset_run_items
|
||||
SET project_id = dataset_runs.project_id
|
||||
FROM dataset_runs
|
||||
WHERE dataset_run_items.dataset_run_id = dataset_runs.id;
|
||||
|
||||
-- Drop the old foreign keys
|
||||
ALTER TABLE "dataset_run_items"
|
||||
DROP CONSTRAINT "dataset_run_items_dataset_item_id_fkey",
|
||||
DROP CONSTRAINT "dataset_run_items_dataset_run_id_fkey";
|
||||
|
||||
ALTER TABLE "dataset_items"
|
||||
DROP CONSTRAINT "dataset_items_dataset_id_fkey";
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
DROP CONSTRAINT "dataset_runs_dataset_id_fkey";
|
||||
|
||||
-- Now alter the columns to NOT NULL and update primary keys
|
||||
ALTER TABLE "datasets"
|
||||
DROP CONSTRAINT "datasets_pkey",
|
||||
ADD CONSTRAINT "datasets_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_items"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_items_pkey",
|
||||
ADD CONSTRAINT "dataset_items_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_runs"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_runs_pkey",
|
||||
ADD CONSTRAINT "dataset_runs_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
ALTER TABLE "dataset_run_items"
|
||||
ALTER COLUMN "project_id" SET NOT NULL,
|
||||
DROP CONSTRAINT "dataset_run_items_pkey",
|
||||
ADD CONSTRAINT "dataset_run_items_pkey" PRIMARY KEY ("id", "project_id");
|
||||
|
||||
|
||||
-- Add new foreign keys
|
||||
ALTER TABLE "dataset_items" ADD CONSTRAINT "dataset_items_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_runs" ADD CONSTRAINT "dataset_runs_dataset_id_project_id_fkey" FOREIGN KEY ("dataset_id", "project_id") REFERENCES "datasets"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_run_id_project_id_fkey" FOREIGN KEY ("dataset_run_id", "project_id") REFERENCES "dataset_runs"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "dataset_run_items" ADD CONSTRAINT "dataset_run_items_dataset_item_id_project_id_fkey" FOREIGN KEY ("dataset_item_id", "project_id") REFERENCES "dataset_items"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY "dataset_runs_dataset_id_project_id_name_key" ON "dataset_runs"("dataset_id", "project_id", "name");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX CONCURRENTLY "dataset_runs_dataset_id_name_key";
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
DROP VIEW IF EXISTS "observations_view"; -- Drop view as column was added in 20240705154048_observation_view_add_created_at_updated_at and update view must have same columns
|
||||
CREATE VIEW "observations_view" AS -- Specify the columns that should be returned in the view, as calculated columns are added but exist in the observations table already
|
||||
SELECT
|
||||
o.id,
|
||||
o.name,
|
||||
o.start_time,
|
||||
o.end_time,
|
||||
o.parent_observation_id,
|
||||
o.type,
|
||||
o.trace_id,
|
||||
o.metadata,
|
||||
o.model,
|
||||
o."modelParameters",
|
||||
o.input,
|
||||
o.output,
|
||||
o.level,
|
||||
o.status_message,
|
||||
o.completion_start_time,
|
||||
o.completion_tokens,
|
||||
o.prompt_tokens,
|
||||
o.total_tokens,
|
||||
o.version,
|
||||
o.project_id,
|
||||
o.created_at,
|
||||
o.updated_at,
|
||||
o.unit,
|
||||
o.prompt_id,
|
||||
p.name as prompt_name, -- added in this change
|
||||
p.version as prompt_version, -- added in this change
|
||||
o.input_cost,
|
||||
o.output_cost,
|
||||
o.total_cost,
|
||||
o.internal_model,
|
||||
m.id AS "model_id",
|
||||
m.start_date AS "model_start_date",
|
||||
m.input_price,
|
||||
m.output_price,
|
||||
m.total_price,
|
||||
m.tokenizer_config AS "tokenizer_config",
|
||||
CASE
|
||||
WHEN o.calculated_input_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.prompt_tokens::decimal * m.input_price
|
||||
ELSE
|
||||
COALESCE(o.calculated_input_cost, o.input_cost)
|
||||
END AS "calculated_input_cost",
|
||||
CASE
|
||||
WHEN o.calculated_output_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
ELSE
|
||||
COALESCE(o.calculated_output_cost, o.output_cost)
|
||||
END AS "calculated_output_cost",
|
||||
CASE
|
||||
WHEN o.calculated_total_cost IS NULL AND o.input_cost IS NULL AND o.output_cost IS NULL AND o.total_cost IS NULL THEN
|
||||
CASE
|
||||
WHEN m.total_price IS NOT NULL AND o.total_tokens IS NOT NULL THEN
|
||||
m.total_price * o.total_tokens
|
||||
ELSE
|
||||
o.prompt_tokens::decimal * m.input_price +
|
||||
o.completion_tokens::decimal * m.output_price
|
||||
END
|
||||
ELSE
|
||||
COALESCE(o.calculated_total_cost, o.total_cost)
|
||||
END AS "calculated_total_cost",
|
||||
CASE WHEN o.end_time IS NULL THEN NULL ELSE (EXTRACT(EPOCH FROM o."end_time") - EXTRACT(EPOCH FROM o."start_time"))::double precision END AS "latency",
|
||||
CASE WHEN o.completion_start_time IS NOT NULL AND o.start_time IS NOT NULL THEN EXTRACT(EPOCH FROM (completion_start_time - start_time))::double precision ELSE NULL END as "time_to_first_token"
|
||||
|
||||
FROM
|
||||
observations o
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
models.*
|
||||
FROM
|
||||
models
|
||||
WHERE (models.project_id = o.project_id OR models.project_id IS NULL)
|
||||
AND models.model_name = o.internal_model
|
||||
AND (models.start_date < o.start_time OR models.start_date IS NULL)
|
||||
AND o.unit::TEXT = models.unit
|
||||
ORDER BY
|
||||
models.project_id ASC, -- in postgres, NULLs are sorted last when ordering ASC
|
||||
models.start_date DESC NULLS LAST -- now, NULLs are sorted last when ordering DESC as well
|
||||
LIMIT 1
|
||||
) m ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
prompts.*
|
||||
FROM
|
||||
prompts
|
||||
WHERE prompts.id = o.prompt_id
|
||||
AND prompts.project_id = o.project_id
|
||||
LIMIT 1
|
||||
) p ON TRUE
|
||||
|
||||
|
||||
-- requirements:
|
||||
-- 1. The view should return all columns from the observations table
|
||||
-- 2. The view should match with only one model for each observation if:
|
||||
-- a. The model has the same project_id as the observation, otherwise the model without project_id.
|
||||
-- b. The model has the same model_name as the observation
|
||||
-- c. The model has a start_date that is less than the observation start_time, otherwise the model without start_date
|
||||
-- d. The model has the same unit as the observation
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
|
||||
INSERT INTO models (
|
||||
id,
|
||||
project_id,
|
||||
model_name,
|
||||
match_pattern,
|
||||
start_date,
|
||||
input_price,
|
||||
output_price,
|
||||
total_price,
|
||||
unit,
|
||||
tokenizer_id,
|
||||
tokenizer_config
|
||||
)
|
||||
VALUES
|
||||
-- gpt-4o-2024-08-06
|
||||
('clzjr85f70000ymmzg7hqffra', NULL, 'gpt-4o-2024-08-06', '(?i)^(gpt-4o-2024-08-06)$', NULL, 0.0000025, 0.000010, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }')
|
||||
|
||||
@@ -376,7 +376,10 @@ view ObservationView {
|
||||
unit String?
|
||||
completionStartTime DateTime? @map("completion_start_time")
|
||||
|
||||
promptId String? @map("prompt_id")
|
||||
// prompts
|
||||
promptId String? @map("prompt_id")
|
||||
promptName String? @map("prompt_name")
|
||||
promptVersion Int? @map("prompt_version")
|
||||
|
||||
// model fields
|
||||
modelId String? @map("model_id")
|
||||
@@ -488,17 +491,18 @@ model CronJobs {
|
||||
}
|
||||
|
||||
model Dataset {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetItems DatasetItem[]
|
||||
datasetRuns DatasetRuns[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@unique([projectId, name])
|
||||
@@index([projectId], type: Hash)
|
||||
@@index([createdAt])
|
||||
@@ -507,7 +511,8 @@ model Dataset {
|
||||
}
|
||||
|
||||
model DatasetItem {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
status DatasetStatus @default(ACTIVE)
|
||||
input Json?
|
||||
expectedOutput Json? @map("expected_output")
|
||||
@@ -517,11 +522,12 @@ model DatasetItem {
|
||||
sourceObservationId String? @map("source_observation_id")
|
||||
sourceObservation Observation? @relation(fields: [sourceObservationId], references: [id], onDelete: SetNull)
|
||||
datasetId String @map("dataset_id")
|
||||
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
|
||||
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([sourceTraceId], type: Hash)
|
||||
@@index([sourceObservationId], type: Hash)
|
||||
@@index([datasetId], type: Hash)
|
||||
@@ -536,17 +542,19 @@ enum DatasetStatus {
|
||||
}
|
||||
|
||||
model DatasetRuns {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
datasetId String @map("dataset_id")
|
||||
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
|
||||
dataset Dataset @relation(fields: [datasetId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
@@unique([datasetId, name])
|
||||
@@id([id, projectId])
|
||||
@@unique([datasetId, projectId, name])
|
||||
@@index([datasetId], type: Hash)
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@ -554,16 +562,18 @@ model DatasetRuns {
|
||||
}
|
||||
|
||||
model DatasetRunItems {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
datasetRunId String @map("dataset_run_id")
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId], references: [id], onDelete: Cascade)
|
||||
datasetRun DatasetRuns @relation(fields: [datasetRunId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
datasetItemId String @map("dataset_item_id")
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId], references: [id], onDelete: Cascade)
|
||||
datasetItem DatasetItem @relation(fields: [datasetItemId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
traceId String @map("trace_id")
|
||||
observationId String? @map("observation_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, projectId])
|
||||
@@index([datasetRunId], type: Hash)
|
||||
@@index([datasetItemId], type: Hash)
|
||||
@@index([observationId], type: Hash)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server";
|
||||
import { encrypt } from "../src/encryption";
|
||||
import { redis } from "../src/server/redis/redis";
|
||||
|
||||
const LOAD_TRACE_VOLUME = 10_000;
|
||||
|
||||
@@ -286,6 +287,7 @@ async function main() {
|
||||
: undefined;
|
||||
const datasetItem = await prisma.datasetItem.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
datasetId: dataset.id,
|
||||
sourceTraceId: sourceObservation?.traceId,
|
||||
sourceObservationId:
|
||||
@@ -312,6 +314,7 @@ async function main() {
|
||||
for (let datasetRunNumber = 0; datasetRunNumber < 5; datasetRunNumber++) {
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
name: `demo-dataset-run-${datasetRunNumber}`,
|
||||
description: Math.random() > 0.5 ? "Dataset run description" : "",
|
||||
datasetId: dataset.id,
|
||||
@@ -336,6 +339,7 @@ async function main() {
|
||||
|
||||
await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
projectId: project2.id,
|
||||
datasetItemId,
|
||||
traceId: observation.traceId as string,
|
||||
observationId: Math.random() > 0.5 ? observation.id : undefined,
|
||||
@@ -351,10 +355,14 @@ async function main() {
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
redis?.disconnect();
|
||||
console.log("Disconnected from postgres and redis");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "crypto";
|
||||
import { env } from "../env";
|
||||
|
||||
const ENCRYPTION_KEY: string = env.ENCRYPTION_KEY || ""; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
|
||||
const IV_LENGTH: number = 16; // For AES, this is always 16
|
||||
|
||||
// Alternatively: openssl rand -hex 32
|
||||
@@ -13,17 +13,16 @@ export function keyGen() {
|
||||
* Encrypts the given plain text using AES-256-GCM algorithm.
|
||||
*
|
||||
* @param {string} plainText - The text to encrypt.
|
||||
* @param {string} [keyHex=ENCRYPTION_KEY] - The encryption key in hexadecimal format.
|
||||
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
|
||||
*/
|
||||
export function encrypt(
|
||||
plainText: string,
|
||||
keyHex: string = ENCRYPTION_KEY
|
||||
): string {
|
||||
export function encrypt(plainText: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
let encrypted = cipher.update(plainText, "utf8", "hex");
|
||||
@@ -34,7 +33,10 @@ export function encrypt(
|
||||
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
|
||||
}
|
||||
|
||||
export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
export function decrypt(text: string): string {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
|
||||
}
|
||||
const [ivHex, encryptedHex, authTagHex] = text.split(":");
|
||||
if (!ivHex || !encryptedHex || !authTagHex) {
|
||||
throw new Error("Invalid or corrupted cipher format");
|
||||
@@ -46,7 +48,7 @@ export function decrypt(text: string, keyHex: string = ENCRYPTION_KEY): string {
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
Buffer.from(keyHex, "hex"),
|
||||
Buffer.from(ENCRYPTION_KEY, "hex"),
|
||||
iv
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
REDIS_HOST: z.string().nullish(),
|
||||
REDIS_PORT: z.coerce
|
||||
.number({
|
||||
description:
|
||||
".env files convert numbers to strings, therefoore we have to enforce them to be numbers",
|
||||
})
|
||||
.positive()
|
||||
.max(65536, `options.port should be >= 0 and < 65536`)
|
||||
.default(6379)
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
@@ -8,6 +23,11 @@ const EnvSchema = z.object({
|
||||
"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"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
|
||||
CLICKHOUSE_URL: z.string().url().optional(),
|
||||
CLICKHOUSE_USER: z.string().optional(),
|
||||
CLICKHOUSE_PASSWORD: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -14,7 +14,7 @@ const NumericData = z.object({
|
||||
});
|
||||
|
||||
const CategoricalData = z.object({
|
||||
value: z.number().optional().nullish(),
|
||||
value: z.number().nullish(),
|
||||
stringValue: z.string(),
|
||||
dataType: z.literal("CATEGORICAL"),
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const CreateAnnotationScoreBase = z.object({
|
||||
traceId: z.string(),
|
||||
configId: z.string().optional(),
|
||||
observationId: z.string().optional(),
|
||||
comment: z.string().optional().nullish(),
|
||||
comment: z.string().nullish(),
|
||||
});
|
||||
|
||||
const UpdateAnnotationScoreBase = CreateAnnotationScoreBase.extend({
|
||||
|
||||
@@ -81,6 +81,7 @@ export const evalModelsByAdapter: Record<LLMAdapter, string[]> = {
|
||||
[LLMAdapter.OpenAI]: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-2024-08-06", // cheaper than gpt-4o
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-3.5-turbo",
|
||||
] as const,
|
||||
|
||||
@@ -10,9 +10,9 @@ export * from "./orderByToPrisma";
|
||||
export * from "./tracesTable";
|
||||
export * from "./server/auth";
|
||||
export * from "./observationsTable";
|
||||
export * from "./features/ingestion/types";
|
||||
export * from "./utils/zod";
|
||||
export * from "./utils/json";
|
||||
export * from "./utils/objects";
|
||||
export { env } from "./env";
|
||||
|
||||
// llm api
|
||||
|
||||
@@ -44,6 +44,7 @@ export enum QueueName {
|
||||
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
|
||||
BatchExport = "batch-export-queue",
|
||||
RepeatQueue = "repeat-queue",
|
||||
IngestionFlushQueue = "ingestion-flush-queue",
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -51,6 +52,7 @@ export enum QueueJobs {
|
||||
EvaluationExecution = "evaluation-execution-job",
|
||||
BatchExportJob = "batch-export-job",
|
||||
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
|
||||
FlushIngestionEntity = "flush-ingestion-entity",
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createClient } from "@clickhouse/client";
|
||||
|
||||
import { env } from "../env";
|
||||
|
||||
export type ClickhouseClientType = ReturnType<typeof createClient>;
|
||||
|
||||
export const clickhouseClient = createClient({
|
||||
url: env.CLICKHOUSE_URL,
|
||||
username: env.CLICKHOUSE_USER,
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: "default",
|
||||
clickhouse_settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import z from "zod";
|
||||
|
||||
export const clickhouseStringDateSchema = z
|
||||
.string()
|
||||
// clickhouse stores UTC like '2024-05-23 18:33:41.602000'
|
||||
// we need to convert it to '2024-05-23T18:33:41.602000Z'
|
||||
.transform((str) => str.replace(" ", "T") + "Z")
|
||||
.pipe(z.string().datetime());
|
||||
|
||||
export const observationRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
trace_id: z.string().nullish(),
|
||||
project_id: z.string(),
|
||||
type: z.string(),
|
||||
parent_observation_id: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
metadata: z.record(z.string()),
|
||||
level: z.string().nullish(),
|
||||
status_message: z.string().nullish(),
|
||||
version: z.string().nullish(),
|
||||
input: z.string().nullish(),
|
||||
output: z.string().nullish(),
|
||||
provided_model_name: z.string().nullish(),
|
||||
internal_model_id: z.string().nullish(),
|
||||
model_parameters: z.string().nullish(),
|
||||
unit: z.string().nullish(),
|
||||
input_usage_units: z.number().nullish(),
|
||||
output_usage_units: z.number().nullish(),
|
||||
total_usage_units: z.number().nullish(),
|
||||
input_cost: z.number().nullish(),
|
||||
output_cost: z.number().nullish(),
|
||||
total_cost: z.number().nullish(),
|
||||
provided_input_usage_units: z.number().nullish(),
|
||||
provided_output_usage_units: z.number().nullish(),
|
||||
provided_total_usage_units: z.number().nullish(),
|
||||
provided_input_cost: z.number().nullish(),
|
||||
provided_output_cost: z.number().nullish(),
|
||||
provided_total_cost: z.number().nullish(),
|
||||
prompt_id: z.string().nullish(),
|
||||
prompt_name: z.string().nullish(),
|
||||
prompt_version: z.number().nullish(),
|
||||
});
|
||||
export type ObservationRecordBaseType = z.infer<
|
||||
typeof observationRecordBaseSchema
|
||||
>;
|
||||
|
||||
export const observationRecordReadSchema = observationRecordBaseSchema.extend({
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
start_time: clickhouseStringDateSchema,
|
||||
end_time: clickhouseStringDateSchema.nullish(),
|
||||
completion_start_time: clickhouseStringDateSchema.nullish(),
|
||||
});
|
||||
export type ObservationRecordReadType = z.infer<
|
||||
typeof observationRecordReadSchema
|
||||
>;
|
||||
|
||||
export const observationRecordInsertSchema = observationRecordBaseSchema.extend(
|
||||
{
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
start_time: z.number(),
|
||||
end_time: z.number().nullish(),
|
||||
completion_start_time: z.number().nullish(),
|
||||
}
|
||||
);
|
||||
export type ObservationRecordInsertType = z.infer<
|
||||
typeof observationRecordInsertSchema
|
||||
>;
|
||||
|
||||
export const traceRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
user_id: z.string().nullish(),
|
||||
metadata: z.record(z.string()),
|
||||
release: z.string().nullish(),
|
||||
version: z.string().nullish(),
|
||||
project_id: z.string(),
|
||||
public: z.boolean(),
|
||||
bookmarked: z.boolean(),
|
||||
tags: z.array(z.string()),
|
||||
input: z.string().nullish(),
|
||||
output: z.string().nullish(),
|
||||
session_id: z.string().nullish(),
|
||||
});
|
||||
export type TraceRecordBaseType = z.infer<typeof traceRecordBaseSchema>;
|
||||
|
||||
export const traceRecordReadSchema = traceRecordBaseSchema.extend({
|
||||
timestamp: clickhouseStringDateSchema,
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
});
|
||||
export type TraceRecordReadType = z.infer<typeof traceRecordReadSchema>;
|
||||
|
||||
export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
|
||||
timestamp: z.number(),
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
});
|
||||
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
|
||||
|
||||
export const scoreRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
trace_id: z.string(),
|
||||
observation_id: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
value: z.union([z.number(), z.string()]).nullish(),
|
||||
source: z.string(),
|
||||
comment: z.string().nullish(),
|
||||
author_user_id: z.string().nullish(),
|
||||
config_id: z.string().nullish(),
|
||||
data_type: z.enum(["NUMERIC", "CATEGORICAL", "BOOLEAN"]).nullish(),
|
||||
string_value: z.string().nullish(),
|
||||
});
|
||||
export type ScoreRecordBaseType = z.infer<typeof scoreRecordBaseSchema>;
|
||||
|
||||
export const scoreRecordReadSchema = scoreRecordBaseSchema.extend({
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
timestamp: clickhouseStringDateSchema,
|
||||
});
|
||||
export type ScoreRecordReadType = z.infer<typeof scoreRecordReadSchema>;
|
||||
|
||||
export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
|
||||
|
||||
export const convertTraceReadToInsert = (
|
||||
record: TraceRecordReadType
|
||||
): TraceRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.created_at).getTime(),
|
||||
timestamp: new Date(record.timestamp).getTime(),
|
||||
};
|
||||
};
|
||||
|
||||
export const convertObservationReadToInsert = (
|
||||
record: ObservationRecordReadType
|
||||
): ObservationRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.created_at).getTime(),
|
||||
start_time: new Date(record.start_time).getTime(),
|
||||
end_time: record.end_time ? new Date(record.end_time).getTime() : undefined,
|
||||
completion_start_time: record.completion_start_time
|
||||
? new Date(record.completion_start_time).getTime()
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertScoreReadToInsert = (
|
||||
record: ScoreRecordReadType
|
||||
): ScoreRecordInsertType => {
|
||||
return {
|
||||
...record,
|
||||
created_at: new Date(record.created_at).getTime(),
|
||||
updated_at: new Date(record.updated_at).getTime(),
|
||||
timestamp: new Date(record.timestamp).getTime(),
|
||||
};
|
||||
};
|
||||
@@ -2,8 +2,17 @@ export * from "./services/S3StorageService";
|
||||
export * from "./services/email/projectInvitation/sendProjectInvitationEmail";
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./auth";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/types";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse";
|
||||
export * from "../server/definitions";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/model-match";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "./redis/redis";
|
||||
export * from "./redis/trace-upsert";
|
||||
export * from "./clickhouse";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Model, Observation, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db";
|
||||
|
||||
export async function findModel(p: {
|
||||
event: {
|
||||
projectId: string;
|
||||
model?: string;
|
||||
unit?: string;
|
||||
startTime?: Date;
|
||||
};
|
||||
existingDbObservation?: Observation;
|
||||
}): Promise<Model | null> {
|
||||
const { event, existingDbObservation } = p;
|
||||
// either get the model from the existing observation
|
||||
// or match pattern on the user provided model name
|
||||
const modelCondition = event.model
|
||||
? Prisma.sql`AND ${event.model} ~ match_pattern`
|
||||
: existingDbObservation?.internalModel
|
||||
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
|
||||
: undefined;
|
||||
if (!modelCondition) return null;
|
||||
|
||||
// unit based on the current event or the existing observation, both can be undefined
|
||||
const mergedUnit = event.unit ?? existingDbObservation?.unit;
|
||||
|
||||
const unitCondition = mergedUnit
|
||||
? Prisma.sql`AND unit = ${mergedUnit}`
|
||||
: Prisma.empty;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
project_id AS "projectId",
|
||||
model_name AS "modelName",
|
||||
match_pattern AS "matchPattern",
|
||||
start_date AS "startDate",
|
||||
input_price AS "inputPrice",
|
||||
output_price AS "outputPrice",
|
||||
total_price AS "totalPrice",
|
||||
unit,
|
||||
tokenizer_id AS "tokenizerId",
|
||||
tokenizer_config AS "tokenizerConfig"
|
||||
FROM
|
||||
models
|
||||
WHERE (project_id = ${event.projectId}
|
||||
OR project_id IS NULL)
|
||||
${modelCondition}
|
||||
${unitCondition}
|
||||
AND (start_date IS NULL OR start_date <= ${
|
||||
event.startTime ? new Date(event.startTime) : new Date()
|
||||
}::timestamp with time zone at time zone 'UTC')
|
||||
ORDER BY
|
||||
project_id ASC,
|
||||
start_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
|
||||
|
||||
return foundModels[0] ?? null;
|
||||
}
|
||||
+70
-58
@@ -168,63 +168,39 @@ const BaseScoreBody = z.object({
|
||||
/**
|
||||
* ScoreBody exactly mirrors `PostScoresBody` in the public API. Please refer there for source of truth.
|
||||
*/
|
||||
export const ScoreBody = z
|
||||
.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
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({
|
||||
value: z.number().refine((val) => val === 0 || val === 1, {
|
||||
message: "Value must be either 0 or 1",
|
||||
}),
|
||||
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(),
|
||||
})
|
||||
),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.dataType) {
|
||||
if (typeof data.value === "number") {
|
||||
if (data.dataType === "CATEGORICAL") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a string for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
} else if (typeof data.value === "string") {
|
||||
if (data.dataType === "NUMERIC") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must be a number for data type ${data.dataType}`,
|
||||
});
|
||||
} else if (data.dataType === "BOOLEAN") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Value must number equal to either 0 or 1 for data type ${data.dataType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export const ScoreBody = z.discriminatedUnion("dataType", [
|
||||
BaseScoreBody.merge(
|
||||
z.object({
|
||||
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({
|
||||
value: z.number().refine((value) => value === 0 || value === 1, {
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
}),
|
||||
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(),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
// LEGACY, only required for backwards compatibility
|
||||
export const LegacySpanPostSchema = z.object({
|
||||
@@ -334,6 +310,16 @@ export const SdkLogEvent = z.object({
|
||||
|
||||
// definitions for the ingestion API
|
||||
|
||||
export const observationTypes = [
|
||||
"observation-create",
|
||||
"observation-update",
|
||||
"generation-create",
|
||||
"generation-update",
|
||||
"span-create",
|
||||
"span-update",
|
||||
"event-create",
|
||||
];
|
||||
|
||||
export const eventTypes = {
|
||||
TRACE_CREATE: "trace-create",
|
||||
SCORE_CREATE: "score-create",
|
||||
@@ -343,7 +329,6 @@ export const eventTypes = {
|
||||
GENERATION_CREATE: "generation-create",
|
||||
GENERATION_UPDATE: "generation-update",
|
||||
SDK_LOG: "sdk-log",
|
||||
|
||||
// LEGACY, only required for backwards compatibility
|
||||
OBSERVATION_CREATE: "observation-create",
|
||||
OBSERVATION_UPDATE: "observation-update",
|
||||
@@ -358,6 +343,7 @@ export const traceEvent = base.extend({
|
||||
type: z.literal(eventTypes.TRACE_CREATE),
|
||||
body: TraceBody,
|
||||
});
|
||||
export type TraceEventType = z.infer<typeof traceEvent>;
|
||||
|
||||
export const eventCreateEvent = base.extend({
|
||||
type: z.literal(eventTypes.EVENT_CREATE),
|
||||
@@ -383,6 +369,7 @@ export const scoreEvent = base.extend({
|
||||
type: z.literal(eventTypes.SCORE_CREATE),
|
||||
body: ScoreBody,
|
||||
});
|
||||
export type ScoreEventType = z.infer<typeof scoreEvent>;
|
||||
export const sdkLogEvent = base.extend({
|
||||
type: z.literal(eventTypes.SDK_LOG),
|
||||
body: SdkLogEvent,
|
||||
@@ -409,10 +396,35 @@ export const ingestionEvent = z.discriminatedUnion("type", [
|
||||
legacyObservationCreateEvent,
|
||||
legacyObservationUpdateEvent,
|
||||
]);
|
||||
export type IngestionEventType = z.infer<typeof ingestionEvent>;
|
||||
|
||||
export const ingestionBatchEvent = z.array(ingestionEvent);
|
||||
export type IngestionBatchEventType = z.infer<typeof ingestionBatchEvent>;
|
||||
|
||||
export const ingestionEventWithProjectId = ingestionEvent.and(
|
||||
z.object({ projectId: z.string() })
|
||||
);
|
||||
export type IngestionEventWithProjectIdType = z.infer<
|
||||
typeof ingestionEventWithProjectId
|
||||
>;
|
||||
|
||||
export const ingestionApiSchema = z.object({
|
||||
batch: ingestionBatchEvent,
|
||||
metadata: jsonSchema.nullish(),
|
||||
});
|
||||
|
||||
export const ingestionApiSchemaWithProjectId = ingestionApiSchema.extend({
|
||||
projectId: z.string(),
|
||||
});
|
||||
export type IngestionApiSchemaWithProjectId = z.infer<
|
||||
typeof ingestionApiSchemaWithProjectId
|
||||
>;
|
||||
|
||||
export type ObservationEvent =
|
||||
| z.infer<typeof legacyObservationCreateEvent>
|
||||
| z.infer<typeof legacyObservationUpdateEvent>
|
||||
| z.infer<typeof eventCreateEvent>
|
||||
| z.infer<typeof spanCreateEvent>
|
||||
| z.infer<typeof spanUpdateEvent>
|
||||
| z.infer<typeof generationCreateEvent>
|
||||
| z.infer<typeof generationUpdateEvent>;
|
||||
@@ -45,8 +45,10 @@ export const ZodModelConfig = z.object({
|
||||
top_p: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
// NOTE: Update docs page when changing this!
|
||||
export const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
@@ -66,6 +68,7 @@ export const openAIModels = [
|
||||
|
||||
export type OpenAIModel = (typeof openAIModels)[number];
|
||||
|
||||
// NOTE: Update docs page when changing this!
|
||||
export const anthropicModels = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-opus-20240229",
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import Redis from "ioredis";
|
||||
import { env } from "./env";
|
||||
import logger from "./logger";
|
||||
import { env } from "../../env";
|
||||
|
||||
const createRedisClient = () => {
|
||||
try {
|
||||
return env.REDIS_CONNECTION_STRING
|
||||
? new Redis(env.REDIS_CONNECTION_STRING, { maxRetriesPerRequest: null })
|
||||
: 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
|
||||
});
|
||||
: 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;
|
||||
} catch (e) {
|
||||
logger.error(e, "Failed to connect to redis");
|
||||
console.error(e, "Failed to connect to redis");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var redis: undefined | ReturnType<typeof createRedisClient>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
QueueJobs,
|
||||
QueueName,
|
||||
TQueueJobTypes,
|
||||
TraceUpsertEventType,
|
||||
} from "../../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { redis } from "./redis";
|
||||
|
||||
let traceUpsertQueue: Queue<TQueueJobTypes[QueueName.TraceUpsert]> | null =
|
||||
null;
|
||||
|
||||
export const getTraceUpsertQueue = () => {
|
||||
if (traceUpsertQueue) return traceUpsertQueue;
|
||||
|
||||
traceUpsertQueue = redis
|
||||
? new Queue<TQueueJobTypes[QueueName.TraceUpsert]>(QueueName.TraceUpsert, {
|
||||
connection: redis,
|
||||
})
|
||||
: null;
|
||||
|
||||
return traceUpsertQueue;
|
||||
};
|
||||
|
||||
export function convertTraceUpsertEventsToRedisEvents(
|
||||
events: TraceUpsertEventType[]
|
||||
) {
|
||||
const uniqueTracesPerProject = events.reduce((acc, event) => {
|
||||
if (!acc.get(event.projectId)) {
|
||||
acc.set(event.projectId, new Set());
|
||||
}
|
||||
acc.get(event.projectId)?.add(event.traceId);
|
||||
return acc;
|
||||
}, new Map<string, Set<string>>());
|
||||
|
||||
const jobs = [...uniqueTracesPerProject.entries()]
|
||||
.map((tracesPerProject) => {
|
||||
const [projectId, traceIds] = tracesPerProject;
|
||||
|
||||
return [...traceIds].map((traceId) => ({
|
||||
name: QueueJobs.TraceUpsert,
|
||||
data: {
|
||||
payload: {
|
||||
projectId,
|
||||
traceId,
|
||||
},
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.TraceUpsert as const,
|
||||
},
|
||||
opts: {
|
||||
removeOnFail: 10000,
|
||||
removeOnComplete: true,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
}));
|
||||
})
|
||||
.flat();
|
||||
return jobs;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Prompt, PrismaClient } from "@prisma/client";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
|
||||
export class PromptService {
|
||||
private cacheEnabled: boolean;
|
||||
private ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | null,
|
||||
private metricIncrementer?: // used for Sentry metrics
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean // used for testing
|
||||
) {
|
||||
this.cacheEnabled =
|
||||
Boolean(redis) &&
|
||||
(cacheEnabled || env.LANGFUSE_CACHE_PROMPT_ENABLED === "true");
|
||||
|
||||
this.ttlSeconds = env.LANGFUSE_CACHE_PROMPT_TTL_SECONDS;
|
||||
|
||||
if (this.cacheEnabled) {
|
||||
this.logInfo("Prompt cache enabled with TTL seconds", this.ttlSeconds);
|
||||
} else {
|
||||
this.logInfo("Prompt cache disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public async getPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
if (await this.shouldUseCache(params)) {
|
||||
const cachedPrompt = await this.getCachedPrompt(params);
|
||||
|
||||
this.incrementMetric(
|
||||
cachedPrompt ? Metrics.PromptCacheHit : Metrics.PromptCacheMiss
|
||||
);
|
||||
|
||||
if (cachedPrompt) {
|
||||
this.logInfo("Returning cached prompt for params", params);
|
||||
|
||||
return cachedPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
const dbPrompt = await this.getDbPrompt(params);
|
||||
|
||||
if ((await this.shouldUseCache(params)) && dbPrompt) {
|
||||
await this.cachePrompt({ ...params, prompt: dbPrompt });
|
||||
|
||||
this.logInfo("Successfully cached prompt for params", params);
|
||||
}
|
||||
|
||||
this.logInfo("Returning DB prompt for params", params);
|
||||
|
||||
return dbPrompt;
|
||||
}
|
||||
|
||||
private async getDbPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
const { projectId, promptName, version, label } = params;
|
||||
|
||||
if (version) {
|
||||
return await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: promptName,
|
||||
version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (label) {
|
||||
return await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: promptName,
|
||||
labels: {
|
||||
has: label,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logError("Invalid prompt params", params);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async shouldUseCache(params: PromptParams): Promise<boolean> {
|
||||
if (!this.cacheEnabled) return false;
|
||||
|
||||
const isLocked = await this.isCacheLocked(params);
|
||||
|
||||
if (isLocked) {
|
||||
this.logInfo("Cache is locked for params", params);
|
||||
}
|
||||
|
||||
return !isLocked;
|
||||
}
|
||||
|
||||
private async getCachedPrompt(params: PromptParams): Promise<Prompt | null> {
|
||||
try {
|
||||
const key = this.getCacheKey(params);
|
||||
const value = await this.redis?.getex(key, "EX", this.ttlSeconds);
|
||||
|
||||
if (value) return JSON.parse(value) as Prompt;
|
||||
} catch (e) {
|
||||
this.logError("Error getting cached prompt", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async cachePrompt(params: PromptParams & { prompt: Prompt }) {
|
||||
try {
|
||||
const keyIndexKey = this.getKeyIndexKey(params);
|
||||
const key = this.getCacheKey(params);
|
||||
const value = JSON.stringify(params.prompt);
|
||||
|
||||
await this.redis?.sadd(keyIndexKey, key);
|
||||
await this.redis?.set(key, value, "EX", this.ttlSeconds);
|
||||
} catch (e) {
|
||||
this.logError("Error caching prompt", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async lockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
await this.redis?.setex(lockKey, 30, "locked");
|
||||
} catch (e) {
|
||||
this.logError("Error locking cache key prefix", lockKey, e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async unlockCache(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
await this.redis?.del(lockKey);
|
||||
} catch (e) {
|
||||
this.logError("Error unlocking cache key prefix", lockKey, e);
|
||||
|
||||
// Don't re-throw error as lock TTL is short and it's not critical
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheLocked(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): Promise<boolean> {
|
||||
const lockKey = this.getLockKey(params);
|
||||
|
||||
try {
|
||||
return Boolean(await this.redis?.exists(lockKey));
|
||||
} catch (e) {
|
||||
this.logError("Error checking if cache is locked", lockKey, e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getLockKey(
|
||||
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">
|
||||
): Promise<void> {
|
||||
if (!this.cacheEnabled) return;
|
||||
|
||||
const cacheKeyPrefix = this.getCacheKeyPrefix(params);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
this.logInfo("Invalidating cache for prefix", cacheKeyPrefix);
|
||||
|
||||
const keyIndexKey = this.getKeyIndexKey(params);
|
||||
const keys = await this.redis?.smembers(keyIndexKey);
|
||||
|
||||
// Delete all keys for the prefix and the key index
|
||||
await this.redis?.del([...(keys ?? []), keyIndexKey]);
|
||||
|
||||
this.logInfo(
|
||||
`Cache invalidated for prefix ${cacheKeyPrefix} in ${Date.now() - startTime}ms`
|
||||
);
|
||||
} catch (e) {
|
||||
this.logError("Error deleting keys for prefix", cacheKeyPrefix, e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheKey(params: PromptParams): string {
|
||||
const prefix = this.getCacheKeyPrefix(params);
|
||||
|
||||
return `${prefix}:${params.version ?? params.label}`;
|
||||
}
|
||||
|
||||
private getCacheKeyPrefix(
|
||||
params: Pick<PromptParams, "projectId" | "promptName">
|
||||
): string {
|
||||
return `prompt:${params.projectId}:${params.promptName}`;
|
||||
}
|
||||
|
||||
private getKeyIndexKey(
|
||||
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);
|
||||
}
|
||||
|
||||
private logInfo(message: string, ...args: any[]) {
|
||||
console.log(`[PromptService] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private incrementMetric(name: Metrics, value: number = 1) {
|
||||
try {
|
||||
this.metricIncrementer?.(name, value);
|
||||
} catch (e) {
|
||||
this.logError("Error incrementing metric", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PromptParams = {
|
||||
projectId: string;
|
||||
promptName: string;
|
||||
} & (
|
||||
| { version: number; label: undefined }
|
||||
| { version: null | undefined; label: string }
|
||||
);
|
||||
|
||||
enum Metrics {
|
||||
PromptCacheHit = "prompt_cache_hit",
|
||||
PromptCacheMiss = "prompt_cache_miss",
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type z } from "zod";
|
||||
import { z } from "zod";
|
||||
import lodash from "lodash";
|
||||
import { jsonSchema } from "./zod";
|
||||
import { JsonNested, jsonSchema, jsonSchemaNullable } from "./zod";
|
||||
|
||||
export const parseJson = (input: string) => {
|
||||
try {
|
||||
@@ -57,3 +57,46 @@ export const mergeJson = (
|
||||
}
|
||||
return lodash.merge(json1, json2);
|
||||
};
|
||||
|
||||
export const parseJsonPrioritised = (
|
||||
json: string
|
||||
): z.infer<typeof jsonSchema> | string | undefined => {
|
||||
try {
|
||||
const parsedJson = JSON.parse(json);
|
||||
if (Object.keys(parsedJson).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const arr = z.array(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (arr.success) {
|
||||
return arr.data;
|
||||
}
|
||||
const obj = z.record(jsonSchemaNullable).safeParse(parsedJson);
|
||||
if (obj.success) {
|
||||
return obj.data;
|
||||
}
|
||||
|
||||
return jsonSchema.parse(parsedJson);
|
||||
} catch (error) {
|
||||
return jsonSchema.parse(json);
|
||||
}
|
||||
};
|
||||
export const convertRecordToJsonSchema = (
|
||||
record: Record<string, string>
|
||||
): JsonNested | undefined => {
|
||||
const jsonSchema: JsonNested = {};
|
||||
|
||||
// if record is empty, return undefined
|
||||
if (Object.keys(record).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key in record) {
|
||||
try {
|
||||
jsonSchema[key] = JSON.parse(record[key]);
|
||||
} catch (e) {
|
||||
jsonSchema[key] = record[key];
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSchema;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
/**
|
||||
* Removes specified keys from an object and returns a new object without those keys.
|
||||
*/
|
||||
|
||||
export function removeObjectKeys<T, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): OmitKeys<T, K> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -17,11 +17,11 @@ const nestedLiteralSchema = z.union([
|
||||
type Root = z.infer<typeof rootLiteralSchema>;
|
||||
type Literal = z.infer<typeof nestedLiteralSchema>;
|
||||
|
||||
type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
|
||||
export type JsonNested = Literal | { [key: string]: JsonNested } | JsonNested[];
|
||||
type Json = Root | { [key: string]: JsonNested } | JsonNested[];
|
||||
|
||||
// Here, you define the schema recursively
|
||||
const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
export const jsonSchemaNullable: z.ZodType<JsonNested> = z.lazy(() =>
|
||||
z.union([
|
||||
nestedLiteralSchema,
|
||||
z.array(jsonSchemaNullable),
|
||||
|
||||
Generated
+1529
-1562
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -74,7 +74,7 @@ RUN apk add --no-cache dumb-init
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma
|
||||
RUN npm install -g --no-package-lock --no-save prisma@5.13.0
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
|
||||
|
||||
@@ -16,6 +16,8 @@ const serverTestConfig = {
|
||||
displayName: "server",
|
||||
testMatch: ["/**/*.servertest.[jt]s?(x)"],
|
||||
testEnvironment: "jest-environment-node",
|
||||
setupFilesAfterEnv: ["<rootDir>/src/__tests__/after-teardown.ts"],
|
||||
globalTeardown: "<rootDir>/src/__tests__/teardown.ts",
|
||||
};
|
||||
|
||||
// To avoid the "Cannot use import statement outside a module" errors while transforming ESM.
|
||||
@@ -37,4 +39,8 @@ const config = {
|
||||
],
|
||||
};
|
||||
|
||||
process.env = Object.assign(process.env, {
|
||||
LANGFUSE_CACHE_API_KEY_ENABLED: "true",
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.65.0",
|
||||
"version": "2.69.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"prettier": "prettier --write ./src *.{ts,js}",
|
||||
"clean": "rm -rf node_modules",
|
||||
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand",
|
||||
"test": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose",
|
||||
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env -- playwright test",
|
||||
"models:migrate": "dotenv -e ../.env -- tsx scripts/model-match.ts",
|
||||
@@ -60,10 +60,9 @@
|
||||
"@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/types": "^8.13.0",
|
||||
"@sentry/nextjs": "8.13.0",
|
||||
"@sentry/node": "8.13.0",
|
||||
"@sentry/profiling-node": "8.13.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
@@ -78,6 +77,7 @@
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -88,6 +88,7 @@
|
||||
"dompurify": "^3.1.5",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip-address": "^9.0.5",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
|
||||
@@ -1007,6 +1007,13 @@ paths:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: version
|
||||
in: query
|
||||
description: Optional filter to only include observations with a certain version.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1181,6 +1188,26 @@ paths:
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: fromUpdatedAt
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include prompt versions created/updated on
|
||||
or after a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toUpdatedAt
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include prompt versions created/updated
|
||||
before a certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1675,7 +1702,7 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/sessions:
|
||||
get:
|
||||
description: Get sessions.
|
||||
description: Get sessions
|
||||
operationId: sessions_list
|
||||
tags:
|
||||
- Sessions
|
||||
@@ -1846,7 +1873,7 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/traces:
|
||||
get:
|
||||
description: Get list of traces.
|
||||
description: Get list of traces
|
||||
operationId: trace_list
|
||||
tags:
|
||||
- Trace
|
||||
@@ -1924,6 +1951,20 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: version
|
||||
in: query
|
||||
description: Optional filter to only include traces with a certain version.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: release
|
||||
in: query
|
||||
description: Optional filter to only include traces with a certain release.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
@@ -1969,32 +2010,44 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The timestamp when the trace was created
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the trace
|
||||
input:
|
||||
nullable: true
|
||||
description: The input data of the trace. Can be any JSON.
|
||||
output:
|
||||
nullable: true
|
||||
description: The output data of the trace. Can be any JSON.
|
||||
sessionId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The session identifier associated with the trace
|
||||
release:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The release version of the application when the trace was created
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The version of the trace
|
||||
userId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The user identifier associated with the trace
|
||||
metadata:
|
||||
nullable: true
|
||||
description: The metadata associated with the trace. Can be any JSON.
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The tags associated with the trace. Can be an array of strings or
|
||||
null.
|
||||
public:
|
||||
type: boolean
|
||||
nullable: true
|
||||
@@ -2101,56 +2154,74 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The unique identifier of the observation
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The trace ID associated with the observation
|
||||
type:
|
||||
type: string
|
||||
description: The type of the observation
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the observation
|
||||
startTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The start time of the observation
|
||||
endTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: The end time of the observation.
|
||||
completionStartTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: The completion start time of the observation
|
||||
model:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The model used for the observation
|
||||
modelParameters:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/MapValue'
|
||||
nullable: true
|
||||
description: The parameters of the model used for the observation
|
||||
input:
|
||||
nullable: true
|
||||
description: The input data of the observation
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The version of the observation
|
||||
metadata:
|
||||
nullable: true
|
||||
description: Additional metadata of the observation
|
||||
output:
|
||||
nullable: true
|
||||
description: The output data of the observation
|
||||
usage:
|
||||
$ref: '#/components/schemas/Usage'
|
||||
nullable: true
|
||||
description: The usage data of the observation
|
||||
level:
|
||||
$ref: '#/components/schemas/ObservationLevel'
|
||||
description: The level of the observation
|
||||
statusMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The status message of the observation
|
||||
parentObservationId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The parent observation ID
|
||||
promptId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The prompt ID associated with the observation
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -2160,41 +2231,58 @@ components:
|
||||
title: ObservationsView
|
||||
type: object
|
||||
properties:
|
||||
promptName:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The name of the prompt associated with the observation
|
||||
promptVersion:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: The version of the prompt associated with the observation
|
||||
modelId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The unique identifier of the model
|
||||
inputPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The price of the input in USD
|
||||
outputPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The price of the output in USD.
|
||||
totalPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The total price in USD.
|
||||
calculatedInputCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated cost of the input in USD
|
||||
calculatedOutputCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated cost of the output in USD
|
||||
calculatedTotalCost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The calculated total cost in USD
|
||||
latency:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The latency in seconds.
|
||||
timeToFirstToken:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: The time to the first token in seconds
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Observation'
|
||||
Usage:
|
||||
@@ -2540,23 +2628,31 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique identifier of the dataset run
|
||||
name:
|
||||
type: string
|
||||
description: Name of the dataset run
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Description of the run
|
||||
metadata:
|
||||
nullable: true
|
||||
description: Metadata of the dataset run
|
||||
datasetId:
|
||||
type: string
|
||||
description: Id of the associated dataset
|
||||
datasetName:
|
||||
type: string
|
||||
description: Name of the associated dataset
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time when the dataset run was created
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: The date and time when the dataset run was last updated
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -2715,8 +2811,8 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Dataset items are upserted on their id. Id needs to be globally
|
||||
unique and cannot be reused across datasets.
|
||||
Dataset items are upserted on their id. Id needs to be unique
|
||||
(project-level) and cannot be reused across datasets.
|
||||
status:
|
||||
$ref: '#/components/schemas/DatasetStatus'
|
||||
nullable: true
|
||||
@@ -3611,11 +3707,20 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
lastUpdatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
lastConfig:
|
||||
description: >-
|
||||
Config object of the most recent prompt version that matches the
|
||||
filters (if any are provided)
|
||||
required:
|
||||
- name
|
||||
- versions
|
||||
- labels
|
||||
- tags
|
||||
- lastUpdatedAt
|
||||
- lastConfig
|
||||
CreatePromptRequest:
|
||||
title: CreatePromptRequest
|
||||
oneOf:
|
||||
|
||||
@@ -718,7 +718,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of observations",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=",
|
||||
"raw": "{{baseUrl}}/api/public/observations?page=&limit=&name=&userId=&type=&traceId=&parentObservationId=&fromStartTime=&toStartTime=&version=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -772,6 +772,11 @@
|
||||
"key": "toStartTime",
|
||||
"value": "",
|
||||
"description": "Retrieve only observations with a start_time before this datetime (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include observations with a certain version."
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
@@ -872,7 +877,7 @@
|
||||
"request": {
|
||||
"description": "Get a list of prompt names with versions and labels",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=",
|
||||
"raw": "{{baseUrl}}/api/public/v2/prompts?name=&label=&tag=&page=&limit=&fromUpdatedAt=&toUpdatedAt=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -907,6 +912,16 @@
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "limit of items per page"
|
||||
},
|
||||
{
|
||||
"key": "fromUpdatedAt",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include prompt versions created/updated on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toUpdatedAt",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include prompt versions created/updated before a certain datetime (ISO 8601)"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
@@ -1261,7 +1276,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get sessions.",
|
||||
"description": "Get sessions",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/sessions?page=&limit=&fromTimestamp=&toTimestamp=",
|
||||
"host": [
|
||||
@@ -1378,9 +1393,9 @@
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get list of traces.",
|
||||
"description": "Get list of traces",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=",
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&toTimestamp=&orderBy=&tags=&version=&release=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -1434,6 +1449,16 @@
|
||||
"key": "tags",
|
||||
"value": "",
|
||||
"description": "Only traces that include all of these tags will be returned."
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces with a certain version."
|
||||
},
|
||||
{
|
||||
"key": "release",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces with a certain release."
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
import "dotenv/config";
|
||||
|
||||
import { findModel } from "@/src/server/api/services/EventProcessor";
|
||||
import { findModel } from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import lodash from "lodash";
|
||||
import { tokenCount } from "@/src/features/ingest/lib/usage";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import teardown from "@/src/__tests__/teardown";
|
||||
|
||||
afterAll(async () => {
|
||||
await teardown();
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
|
||||
describe("aggregateScores", () => {
|
||||
it("should return an empty object for an empty array", () => {
|
||||
const scores: APIScore[] = [];
|
||||
expect(aggregateScores(scores)).toEqual({});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single numeric score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple numeric scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5, 7],
|
||||
average: 6,
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with different keys", () => {
|
||||
const scores: APIScore[] = [
|
||||
{
|
||||
name: "test1",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test1",
|
||||
source: "ANNOTATION",
|
||||
dataType: "NUMERIC",
|
||||
value: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test1-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test1-ANNOTATION-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [7],
|
||||
average: 7,
|
||||
comment: "another comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate a single Categorical score", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "test comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good"],
|
||||
valueCounts: [{ value: "good", count: 1 }],
|
||||
comment: "test comment",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate multiple Categorical scores with the same key", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "True",
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "BOOLEAN",
|
||||
stringValue: "False",
|
||||
comment: "another comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-BOOLEAN": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["True", "False"],
|
||||
valueCounts: [
|
||||
{ value: "True", count: 1 },
|
||||
{ value: "False", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should correctly aggregate scores with mixed types and the same name", () => {
|
||||
const scores = [
|
||||
{
|
||||
name: "test",
|
||||
source: "API",
|
||||
dataType: "NUMERIC",
|
||||
value: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "another comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "bad",
|
||||
comment: "last comment",
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
source: "ANNOTATION",
|
||||
dataType: "CATEGORICAL",
|
||||
stringValue: "good",
|
||||
comment: "last comment",
|
||||
},
|
||||
] as APIScore[];
|
||||
expect(aggregateScores(scores)).toEqual({
|
||||
"test-API-NUMERIC": {
|
||||
type: "NUMERIC",
|
||||
values: [5],
|
||||
average: 5,
|
||||
comment: "test comment",
|
||||
},
|
||||
"test-ANNOTATION-CATEGORICAL": {
|
||||
type: "CATEGORICAL",
|
||||
values: ["good", "bad", "good"],
|
||||
valueCounts: [
|
||||
{ value: "good", count: 2 },
|
||||
{ value: "bad", count: 1 },
|
||||
],
|
||||
comment: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,87 +2,398 @@ import {
|
||||
getDisplaySecretKey,
|
||||
hashSecretKey,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
ApiAuthService,
|
||||
ApiKeyZod,
|
||||
} from "@/src/features/public-api/server/apiAuth";
|
||||
import { type PrismaClient, prisma } from "@langfuse/shared/src/db";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
describe("Validate api calls", () => {
|
||||
describe("Authenticate API calls", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.score.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.apiKey.deleteMany();
|
||||
});
|
||||
|
||||
it("should create new api key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
afterEach(async () => {
|
||||
await prisma.score.deleteMany();
|
||||
await prisma.observation.deleteMany();
|
||||
await prisma.trace.deleteMany();
|
||||
await prisma.apiKey.deleteMany();
|
||||
});
|
||||
|
||||
it("should create new api key and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
describe("validates without redis", () => {
|
||||
it("should create new api key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const auth2 = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth2.validKey).toBe(true);
|
||||
it("should create new api key and succeed with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth2.validKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const wrongAuth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(wrongAuth.validKey).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail on wrong api key without new key", async () => {
|
||||
await createAPIKey();
|
||||
const initialApiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(initialApiKey).not.toBeNull();
|
||||
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
|
||||
|
||||
const auth = await new ApiAuthService(
|
||||
prisma,
|
||||
null,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(auth.validKey).toBe(false);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail on wrong api key with new key", async () => {
|
||||
await createAPIKey();
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(auth.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
describe("validates with redis", () => {
|
||||
const redis = new Redis("redis://:myredissecret@127.0.0.1:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const wrongAuth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(wrongAuth.validKey).toBe(false);
|
||||
});
|
||||
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
|
||||
|
||||
it("should fail on wrong api key without new key", async () => {
|
||||
await createAPIKey();
|
||||
const initialApiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
const keys = await redis.keys("api-key*");
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
expect(initialApiKey).not.toBeNull();
|
||||
expect(initialApiKey?.fastHashedSecretKey).toBeNull();
|
||||
|
||||
const auth = await verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkx",
|
||||
);
|
||||
expect(auth.validKey).toBe(false);
|
||||
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*");
|
||||
if (keys.length > 0) {
|
||||
await redis.del(keys);
|
||||
}
|
||||
});
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it("should create new api key and read from cache", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).toBeNull();
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const cachedKey2 = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
|
||||
expect(cachedKey2).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey2!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("searching for non-existing key stores flag in redis and fails auth", async () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
|
||||
const redisKeys = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys.length).toBe(1);
|
||||
const redisValue = await redis.get(redisKeys[0]);
|
||||
expect(redisValue).toBe('"api-key-non-existent"');
|
||||
});
|
||||
|
||||
it("searching for non-existing key again fails auth", async () => {
|
||||
// key does not exist in database
|
||||
|
||||
const verification = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(verification.validKey).toBe(false);
|
||||
|
||||
const redisKeys = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys.length).toBe(1);
|
||||
const redisValue = await redis.get(redisKeys[0]);
|
||||
expect(redisValue).toBe('"api-key-non-existent"');
|
||||
|
||||
const verification2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
expect(verification2.validKey).toBe(false);
|
||||
|
||||
const redisKeys2 = await redis.keys(`api-key:*`);
|
||||
expect(redisKeys2.length).toBe(1);
|
||||
const redisValue2 = await redis.get(redisKeys[0]);
|
||||
expect(redisValue2).toBe('"api-key-non-existent"');
|
||||
});
|
||||
|
||||
it("prisma should not be used when reading cached keys", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// Mock prisma
|
||||
const mockPrisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
|
||||
// Ensure prisma was not called
|
||||
expect(mockPrisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
id: expect.any(String),
|
||||
note: "seeded key",
|
||||
publicKey: "pk-lf-1234567890",
|
||||
hashedSecretKey: expect.any(String),
|
||||
fastHashedSecretKey: expect.any(String),
|
||||
displaySecretKey: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
projectId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("ttl should be increased when reading from redis", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// Mock prisma
|
||||
const mockPrisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// third will read from redis only
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl = await redis.ttl(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
|
||||
expect(ttl).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
|
||||
// wait for 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await new ApiAuthService(
|
||||
mockPrisma as unknown as PrismaClient,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
const ttl2 = await redis.ttl(
|
||||
"api-key:ed6818ada09bdad405a74ac72773dde1708dd3fc6fe8bb81b59927400419d227",
|
||||
);
|
||||
|
||||
expect(ttl2).toBeGreaterThan(env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS - 2);
|
||||
}, 10000);
|
||||
|
||||
it("should delete API keys from cache and db", async () => {
|
||||
await createAPIKey();
|
||||
|
||||
// first auth will generate the fast hashed api key
|
||||
await new ApiAuthService(prisma, redis).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
// second will add the key to redis
|
||||
const auth2 = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(
|
||||
"Basic cGstbGYtMTIzNDU2Nzg5MDpzay1sZi0xMjM0NTY3ODkw",
|
||||
);
|
||||
|
||||
expect(auth2.validKey).toBe(true);
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { publicKey: "pk-lf-1234567890" },
|
||||
});
|
||||
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).not.toBeNull();
|
||||
|
||||
const cachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(cachedKey).not.toBeNull();
|
||||
|
||||
const parsed = ApiKeyZod.parse(JSON.parse(cachedKey!));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
...apiKey,
|
||||
createdAt: apiKey?.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
await new ApiAuthService(prisma, redis).deleteApiKey(
|
||||
apiKey?.id!,
|
||||
apiKey?.projectId!,
|
||||
);
|
||||
|
||||
const deletedApiKey = await prisma.apiKey.findUnique({
|
||||
where: { id: apiKey?.id! },
|
||||
});
|
||||
expect(deletedApiKey).toBeNull();
|
||||
|
||||
const deletedCachedKey = await redis.get(
|
||||
`api-key:${apiKey?.fastHashedSecretKey}`,
|
||||
);
|
||||
expect(deletedCachedKey).toBeNull();
|
||||
});
|
||||
expect(apiKey).not.toBeNull();
|
||||
expect(apiKey?.fastHashedSecretKey).toBeNull();
|
||||
});
|
||||
|
||||
const createAPIKey = async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PostDatasetsV1Response,
|
||||
PostDatasetsV2Response,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
describe("/api/public/datasets and /api/public/dataset-items API Endpoints", () => {
|
||||
const traceId = v4();
|
||||
@@ -295,12 +296,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
},
|
||||
});
|
||||
expect(dbDatasetItems.length).toBe(5);
|
||||
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map((item) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}));
|
||||
const dbDatasetItemsApiResponseFormat = dbDatasetItems.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...item }) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}),
|
||||
);
|
||||
|
||||
// add another dataset to test the list endpoint
|
||||
await makeZodVerifiedAPICall(
|
||||
@@ -333,7 +337,8 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
});
|
||||
expect(dbDatasetItemsOther.length).toBe(1);
|
||||
const dbDatasetItemsOtherApiResponseFormat = dbDatasetItemsOther.map(
|
||||
(item) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...item }) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
@@ -784,12 +789,15 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
},
|
||||
});
|
||||
expect(dbRuns.length).toBe(3);
|
||||
const dbRunsApiResponseFormat = dbRuns.map((run) => ({
|
||||
...run,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
updatedAt: run.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}));
|
||||
const dbRunsApiResponseFormat = dbRuns.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ projectId, ...run }) => ({
|
||||
...run,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
updatedAt: run.updatedAt.toISOString(),
|
||||
datasetName: "dataset-name",
|
||||
}),
|
||||
);
|
||||
|
||||
// test get runs
|
||||
const getRuns = await makeZodVerifiedAPICall(
|
||||
@@ -835,4 +843,89 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("dataset item ids should be reusable across projects", async () => {
|
||||
const otherProject = await prisma.project.create({
|
||||
data: {
|
||||
name: "other-project",
|
||||
},
|
||||
});
|
||||
|
||||
// dataset ids are always generated
|
||||
const datasetBody = {
|
||||
name: "dataset-name",
|
||||
};
|
||||
// dataset, id is generated
|
||||
const apiDataset = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response,
|
||||
"POST",
|
||||
"/api/public/datasets",
|
||||
{ ...datasetBody, metadata: "api-dataset" },
|
||||
);
|
||||
const otherProjDbDataset = await prisma.dataset.create({
|
||||
data: {
|
||||
...datasetBody,
|
||||
projectId: otherProject.id,
|
||||
id: apiDataset.body.id, // use the same id, not possible via api, done to check security of this
|
||||
},
|
||||
});
|
||||
const getApiDataset = await makeZodVerifiedAPICall(
|
||||
GetDatasetV1Response,
|
||||
"GET",
|
||||
`/api/public/datasets/${encodeURIComponent(datasetBody.name)}`,
|
||||
);
|
||||
expect(getApiDataset.body.metadata).toBe("api-dataset");
|
||||
|
||||
// item ids can be set by the user
|
||||
const datasetItemBody = {
|
||||
input: "item-input",
|
||||
id: uuidv4(),
|
||||
};
|
||||
await prisma.datasetItem.create({
|
||||
data: {
|
||||
...datasetItemBody,
|
||||
expectedOutput: "other-proj",
|
||||
projectId: otherProject.id,
|
||||
datasetId: otherProjDbDataset.id,
|
||||
},
|
||||
});
|
||||
|
||||
// dataset item, id is set
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
...datasetItemBody,
|
||||
expectedOutput: "api-item",
|
||||
datasetName: datasetBody.name,
|
||||
metadata: "api-item",
|
||||
},
|
||||
);
|
||||
const getApiDatasetItem = await makeZodVerifiedAPICall(
|
||||
GetDatasetItemV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-items/${datasetItemBody.id}`,
|
||||
);
|
||||
expect(getApiDatasetItem.body.metadata).toBe("api-item");
|
||||
const dbItems = await prisma.datasetItem.findMany({
|
||||
where: { id: datasetItemBody.id },
|
||||
});
|
||||
expect(dbItems.length).toBe(2);
|
||||
expect(dbItems).toHaveLength(2);
|
||||
expect(dbItems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata: "api-item",
|
||||
projectId: apiDataset.body.projectId,
|
||||
id: datasetItemBody.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
metadata: null,
|
||||
projectId: otherProject.id,
|
||||
id: datasetItemBody.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,10 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
fetchedObservations.body.data[0]?.calculatedTotalCost,
|
||||
).toBeGreaterThan(0);
|
||||
expect(fetchedObservations.body.data[0]?.promptId).toBe(prompt.id);
|
||||
expect(fetchedObservations.body.data[0]?.promptName).toBe(prompt.name);
|
||||
expect(fetchedObservations.body.data[0]?.promptVersion).toBe(
|
||||
prompt.version,
|
||||
);
|
||||
});
|
||||
it("should fetch all observations, filtered by generations", async () => {
|
||||
await pruneDatabase();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { type PrismaClient, type Prompt } from "@prisma/client";
|
||||
import { PromptService, type redis } from "@langfuse/shared/src/server"; // Adjust the import path as needed
|
||||
|
||||
type Redis = NonNullable<typeof redis>;
|
||||
|
||||
describe("PromptService", () => {
|
||||
let promptService: PromptService;
|
||||
let mockPrisma: jest.Mocked<PrismaClient>;
|
||||
let mockRedis: jest.Mocked<Redis>;
|
||||
let mockMetricIncrementer: jest.Mock;
|
||||
|
||||
const mockPrompt: Omit<Prompt, "updatedAt" | "createdAt"> = {
|
||||
id: "1",
|
||||
projectId: "project1",
|
||||
name: "testPrompt",
|
||||
version: 1,
|
||||
prompt: "Test prompt content",
|
||||
labels: ["test"],
|
||||
createdBy: "API",
|
||||
type: "text",
|
||||
isActive: null,
|
||||
config: {},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
prompt: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
} as unknown as jest.Mocked<PrismaClient>;
|
||||
|
||||
mockRedis = {
|
||||
getex: jest.fn(),
|
||||
set: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
eval: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
} as unknown as jest.Mocked<Redis>;
|
||||
|
||||
mockMetricIncrementer = jest.fn();
|
||||
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
mockRedis,
|
||||
mockMetricIncrementer,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe("getPrompt", () => {
|
||||
it("should return cached prompt if available", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(JSON.stringify(mockPrompt));
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith("prompt_cache_hit", 1);
|
||||
});
|
||||
|
||||
it("should fetch from database if not in cache", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
"prompt:project1:testPrompt:1",
|
||||
JSON.stringify(mockPrompt),
|
||||
"EX",
|
||||
3600,
|
||||
);
|
||||
|
||||
expect(mockRedis.sadd).toHaveBeenCalledWith(
|
||||
"prompt_key_index:project1:testPrompt",
|
||||
"prompt:project1:testPrompt:1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache if locked", async () => {
|
||||
mockRedis.exists.mockResolvedValue(1);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockCache", () => {
|
||||
it("should set a lock in Redis", async () => {
|
||||
await promptService.lockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.setex).toHaveBeenCalledWith(
|
||||
"LOCK:prompt:project1:testPrompt",
|
||||
30,
|
||||
"locked",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockCache", () => {
|
||||
it("should remove the lock from Redis", async () => {
|
||||
await promptService.unlockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
"LOCK:prompt:project1:testPrompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateCache", () => {
|
||||
it("should call deleteKeysByPrefix with correct prefix", async () => {
|
||||
await promptService.invalidateCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
|
||||
expect(mockRedis.smembers).toHaveBeenCalledWith(
|
||||
"prompt_key_index:project1:testPrompt",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching disabled", () => {
|
||||
beforeEach(() => {
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
mockRedis,
|
||||
mockMetricIncrementer,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache when disabled", async () => {
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockRedis.getex).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("null Redis instance", () => {
|
||||
beforeEach(() => {
|
||||
promptService = new PromptService(
|
||||
mockPrisma,
|
||||
null,
|
||||
mockMetricIncrementer,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not use cache with null Redis instance", async () => {
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrompt with Redis errors", () => {
|
||||
it("should fallback to database if Redis.exists throws an error", async () => {
|
||||
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback to database if Redis.getex throws an error", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockRejectedValue(new Error("Redis error"));
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not cache if Redis.set throws an error after database fetch", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0);
|
||||
mockRedis.getex.mockResolvedValue(null);
|
||||
mockPrisma.prompt.findFirst.mockResolvedValue(mockPrompt);
|
||||
mockRedis.set.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
const result = await promptService.getPrompt({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
version: 1,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockPrompt);
|
||||
expect(mockPrisma.prompt.findFirst).toHaveBeenCalled();
|
||||
expect(mockMetricIncrementer).toHaveBeenCalledWith(
|
||||
"prompt_cache_miss",
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockCache with Redis errors", () => {
|
||||
it("should throw an error if Redis.setex fails", async () => {
|
||||
mockRedis.setex.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await expect(
|
||||
promptService.lockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
}),
|
||||
).rejects.toThrow("Redis error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockCache with Redis errors", () => {
|
||||
it("should log error but not throw if Redis.del fails", async () => {
|
||||
mockRedis.del.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await promptService.unlockCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateCache with Redis errors", () => {
|
||||
it("should throw an error if Redis.eval fails", async () => {
|
||||
mockRedis.smembers.mockRejectedValue(new Error("Redis error"));
|
||||
|
||||
await expect(
|
||||
promptService.invalidateCache({
|
||||
projectId: "project1",
|
||||
promptName: "testPrompt",
|
||||
}),
|
||||
).rejects.toThrow("Redis error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,13 @@ type CreatePromptInDBParams = {
|
||||
prompt: string;
|
||||
labels: string[];
|
||||
version: number;
|
||||
config: Record<string, object | number | string>;
|
||||
config: any;
|
||||
projectId: string;
|
||||
createdBy: string;
|
||||
type?: PromptType;
|
||||
tags?: string[];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
const createPromptInDB = async (params: CreatePromptInDBParams) => {
|
||||
return await prisma.prompt.create({
|
||||
@@ -44,6 +46,8 @@ const createPromptInDB = async (params: CreatePromptInDBParams) => {
|
||||
createdBy: params.createdBy,
|
||||
type: params.type,
|
||||
tags: params.tags,
|
||||
createdAt: params.createdAt,
|
||||
updatedAt: params.updatedAt,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -75,7 +79,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
const projectId = uuidv4();
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/v2/prompts`,
|
||||
baseURI,
|
||||
undefined,
|
||||
`Bearer ${projectId}`,
|
||||
);
|
||||
@@ -833,20 +837,23 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Validate prompt-1 meta
|
||||
expect(promptMeta1.name).toBe("prompt-1");
|
||||
expect(promptMeta1.versions).toEqual([1, 2, 4]);
|
||||
expect(promptMeta1.labels).toEqual(["production"]);
|
||||
expect(promptMeta1.labels).toEqual(["production", "version2"]);
|
||||
expect(promptMeta1.tags).toEqual([]);
|
||||
expect(promptMeta1.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate prompt-2 meta
|
||||
expect(promptMeta2.name).toBe("prompt-2");
|
||||
expect(promptMeta2.versions).toEqual([1, 2, 3]);
|
||||
expect(promptMeta2.labels).toEqual(["dev", "production", "staging"]);
|
||||
expect(promptMeta2.tags).toEqual([]);
|
||||
expect(promptMeta2.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate prompt-3 meta
|
||||
expect(promptMeta3.name).toBe("prompt-3");
|
||||
expect(promptMeta3.versions).toEqual([1]);
|
||||
expect(promptMeta3.labels).toEqual(["production"]);
|
||||
expect(promptMeta3.tags).toEqual(["tag-1"]);
|
||||
expect(promptMeta3.lastUpdatedAt).toBeDefined();
|
||||
|
||||
// Validate pagination
|
||||
expect(body.meta.page).toBe(1);
|
||||
@@ -870,7 +877,7 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe("prompt-1");
|
||||
expect(body.data[0].versions).toEqual([1, 2, 4]);
|
||||
expect(body.data[0].labels).toEqual(["production"]);
|
||||
expect(body.data[0].labels).toEqual(["production", "version2"]);
|
||||
expect(body.data[0].tags).toEqual([]);
|
||||
|
||||
// Validate pagination
|
||||
@@ -991,6 +998,112 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(body.meta.totalPages).toBe(3);
|
||||
expect(body.meta.totalItems).toBe(3);
|
||||
});
|
||||
|
||||
it("should fetch lastConfig correctly for a prompt with multiple versions", async () => {
|
||||
// no filters
|
||||
const response = await makeAPICall("GET", `${baseURI}`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body.data).toHaveLength(3);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-2")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.data.some((promptMeta) => promptMeta.name === "prompt-3")).toBe(
|
||||
true,
|
||||
);
|
||||
const prompt1 = body.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-1",
|
||||
);
|
||||
expect(prompt1).toBeDefined();
|
||||
expect(prompt1?.lastConfig).toEqual({ version: 4 });
|
||||
|
||||
const prompt2 = body.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-2",
|
||||
);
|
||||
expect(prompt2).toBeDefined();
|
||||
expect(prompt2?.lastConfig).toEqual({});
|
||||
|
||||
// validate with label filter
|
||||
const response2 = await makeAPICall("GET", `${baseURI}?label=version2`);
|
||||
expect(response2.status).toBe(200);
|
||||
const body2 = response2.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body2.data).toHaveLength(1);
|
||||
expect(body2.data[0].name).toBe("prompt-1");
|
||||
expect(body2.data[0].lastConfig).toEqual({ version: 2 });
|
||||
|
||||
// validate with version filter
|
||||
const response3 = await makeAPICall("GET", `${baseURI}?version=1`);
|
||||
expect(response3.status).toBe(200);
|
||||
const body3 = response3.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body3.data).toHaveLength(3);
|
||||
const prompt1v1 = body3.data.find(
|
||||
(promptMeta) => promptMeta.name === "prompt-1",
|
||||
);
|
||||
expect(prompt1v1?.lastConfig).toEqual({ version: 1 });
|
||||
});
|
||||
|
||||
it("should respect the fromUpdatedAt and toUpdatedAt filters on GET /prompts", async () => {
|
||||
// to and from
|
||||
const from = new Date("2024-01-02T00:00:00.000Z");
|
||||
const to = new Date("2024-01-04T00:00:00.000Z");
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?fromUpdatedAt=${from.toISOString()}&toUpdatedAt=${to.toISOString()}`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = response.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe("prompt-1");
|
||||
expect(body.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
|
||||
expect(body.data[0].versions.length).toBe(1);
|
||||
|
||||
expect(body.meta.totalItems).toBe(1);
|
||||
|
||||
// only from
|
||||
const response2 = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?fromUpdatedAt=${from.toISOString()}`,
|
||||
);
|
||||
expect(response2.status).toBe(200);
|
||||
const body2 = response2.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body2.data).toHaveLength(1);
|
||||
expect(body2.data[0].name).toBe("prompt-1");
|
||||
expect(body2.data[0].lastUpdatedAt).toBe("2024-01-04T00:00:00.000Z");
|
||||
expect(body2.data[0].versions.length).toBe(2);
|
||||
|
||||
expect(body2.meta.totalItems).toBe(1);
|
||||
|
||||
// only to
|
||||
const response3 = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}?toUpdatedAt=${to.toISOString()}`,
|
||||
);
|
||||
expect(response3.status).toBe(200);
|
||||
const body3 = response3.body as unknown as PromptsMetaResponse;
|
||||
|
||||
expect(body3.data).toHaveLength(3);
|
||||
expect(body3.data[0].name).toBe("prompt-1");
|
||||
expect(body3.data[0].lastUpdatedAt).toBe("2024-01-02T00:00:00.000Z");
|
||||
expect(body3.data[0].versions.length).toBe(2);
|
||||
|
||||
expect(body3.data[1].name).toBe("prompt-2");
|
||||
expect(body3.data[1].lastUpdatedAt).toBe("2000-03-01T00:00:00.000Z");
|
||||
expect(body3.data[1].versions.length).toBe(3);
|
||||
|
||||
expect(body3.data[2].name).toBe("prompt-3");
|
||||
expect(body3.data[2].lastUpdatedAt).toBe("2000-01-01T00:00:00.000Z");
|
||||
expect(body3.data[2].versions.length).toBe(1);
|
||||
|
||||
expect(body3.meta.totalItems).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
const isPrompt = (x: unknown): x is Prompt => {
|
||||
@@ -1029,17 +1142,19 @@ const mockPrompts = [
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 1 },
|
||||
version: 1,
|
||||
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-1",
|
||||
labels: ["production"],
|
||||
labels: ["production", "version2"],
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 2 },
|
||||
version: 2,
|
||||
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-1",
|
||||
@@ -1047,8 +1162,9 @@ const mockPrompts = [
|
||||
prompt: "prompt-1",
|
||||
createdBy: "user-test",
|
||||
projectId,
|
||||
config: {},
|
||||
config: { version: 4 },
|
||||
version: 4,
|
||||
updatedAt: new Date("2024-01-04T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt with different labels
|
||||
@@ -1060,6 +1176,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1069,6 +1186,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 2,
|
||||
updatedAt: new Date("2000-03-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
name: "prompt-2",
|
||||
@@ -1078,6 +1196,7 @@ const mockPrompts = [
|
||||
projectId,
|
||||
config: {},
|
||||
version: 3,
|
||||
updatedAt: new Date("2000-02-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt with different labels
|
||||
@@ -1090,6 +1209,7 @@ const mockPrompts = [
|
||||
config: {},
|
||||
tags: ["tag-1"],
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
|
||||
// Prompt in different project
|
||||
@@ -1101,5 +1221,6 @@ const mockPrompts = [
|
||||
projectId: "239ad00f-562f-411d-af14-831c75ddd875",
|
||||
config: {},
|
||||
version: 1,
|
||||
updatedAt: new Date("2000-01-01T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -597,7 +597,8 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
error: [
|
||||
{
|
||||
code: "custom",
|
||||
message: "Value must be either 0 or 1",
|
||||
message:
|
||||
"Value must be a number equal to either 0 or 1 for data type BOOLEAN",
|
||||
path: ["value"],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default async function teardown() {
|
||||
const { redis } = await import("@langfuse/shared/src/server");
|
||||
console.log(`Redis status ${redis?.status}`);
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
if (redis.status === "end" || redis.status === "close") {
|
||||
console.log("Redis connection already closed");
|
||||
return;
|
||||
}
|
||||
redis?.disconnect();
|
||||
console.log("Teardown complete");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModelUsageUnit } from "@langfuse/shared";
|
||||
import { tokenCount } from "@/src/features/ingest/lib/usage";
|
||||
import { tokenCount } from "@/src/features/ingest/usage";
|
||||
|
||||
describe("Token Count Functions", () => {
|
||||
const generateModel = (model: string, tokenizer: string) => {
|
||||
|
||||
@@ -11,30 +11,18 @@ import {
|
||||
} from "@/src/components/ui/popover";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { type DateRange } from "react-day-picker";
|
||||
import { addMinutes, format } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type DateTimeAggregationOption,
|
||||
dateTimeAggregationSettings,
|
||||
dateTimeAggregationOptions,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import { type DashboardDateRange } from "@/src/pages/project/[projectId]";
|
||||
import { isValidOption } from "@/src/utils/types";
|
||||
import { setBeginningOfDay, setEndOfDay } from "@/src/utils/dates";
|
||||
import { TimePicker } from "@/src/components/ui/time-picker";
|
||||
|
||||
export const DEFAULT_DATE_RANGE_SELECTION = "Date range" as const;
|
||||
export type AvailableDateRangeSelections =
|
||||
| typeof DEFAULT_DATE_RANGE_SELECTION
|
||||
| DateTimeAggregationOption;
|
||||
import { DashboardDateRangeDropdown } from "@/src/components/date-range-dropdowns";
|
||||
import {
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type DashboardDateRange,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { combineDateAndTime } from "@/src/components/ui/time-picker-utils";
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
@@ -101,9 +89,9 @@ export function DatePicker({
|
||||
export type DatePickerWithRangeProps = {
|
||||
dateRange?: DashboardDateRange;
|
||||
className?: string;
|
||||
selectedOption: AvailableDateRangeSelections;
|
||||
selectedOption: DashboardDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: AvailableDateRangeSelections,
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
@@ -122,18 +110,35 @@ export function DatePickerWithRange({
|
||||
setInternalDateRange(dateRange);
|
||||
}, [dateRange]);
|
||||
|
||||
const onDropDownSelection = (value: string) => {
|
||||
if (isValidOption(value)) {
|
||||
const setting = dateTimeAggregationSettings[value];
|
||||
const fromDate = addMinutes(new Date(), -1 * setting.minutes);
|
||||
const setNewDateRange = (
|
||||
internalDateRange: DateRange | undefined,
|
||||
newFromDate: Date | undefined,
|
||||
newToDate: Date | undefined,
|
||||
): DateRange | undefined => {
|
||||
return internalDateRange
|
||||
? {
|
||||
from: newFromDate ?? internalDateRange.from,
|
||||
to: newToDate ?? internalDateRange.to,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
setDateRangeAndOption(value, {
|
||||
from: fromDate,
|
||||
to: new Date(),
|
||||
});
|
||||
setInternalDateRange({ from: fromDate, to: new Date() });
|
||||
} else {
|
||||
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, undefined);
|
||||
const updateDashboardDateRange = (
|
||||
newRange: DateRange | undefined,
|
||||
setDateRangeAndOption: (
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void,
|
||||
) => {
|
||||
if (newRange && newRange.from && newRange.to) {
|
||||
const dashboardDateRange: DashboardDateRange = {
|
||||
from: newRange.from,
|
||||
to: newRange.to,
|
||||
};
|
||||
setDateRangeAndOption(
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
dashboardDateRange,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,13 +151,29 @@ export function DatePickerWithRange({
|
||||
: undefined;
|
||||
|
||||
setInternalDateRange(newRange);
|
||||
if (newRange && newRange.from && newRange.to) {
|
||||
const dashboardDateRange: DashboardDateRange = {
|
||||
from: newRange.from,
|
||||
to: newRange.to,
|
||||
};
|
||||
setDateRangeAndOption(DEFAULT_DATE_RANGE_SELECTION, dashboardDateRange);
|
||||
}
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const onStartTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.from, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
newDateTime,
|
||||
internalDateRange?.to,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const onEndTimeSelection = (date: Date | undefined) => {
|
||||
const newDateTime = combineDateAndTime(internalDateRange?.to, date);
|
||||
const newRange = setNewDateRange(
|
||||
internalDateRange,
|
||||
internalDateRange?.from,
|
||||
newDateTime,
|
||||
);
|
||||
setInternalDateRange(newRange);
|
||||
updateDashboardDateRange(newRange, setDateRangeAndOption);
|
||||
};
|
||||
|
||||
const isSmallScreen = useMediaQuery({ query: "(max-width: 640px)" });
|
||||
@@ -193,28 +214,54 @@ export function DatePickerWithRange({
|
||||
defaultMonth={internalDateRange?.from}
|
||||
selected={internalDateRange}
|
||||
onSelect={onCalendarSelection}
|
||||
numberOfMonths={isSmallScreen ? 1 : 2} // TODO: make this configurable to screen size
|
||||
numberOfMonths={isSmallScreen ? 1 : 2}
|
||||
/>
|
||||
{!isSmallScreen && (
|
||||
<div className="flex flex-row border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End time</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSmallScreen && (
|
||||
<div className="flex flex-col gap-2 border-t-2 py-1.5">
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">Start</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.from}
|
||||
setDate={onStartTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<p className="px-1 text-sm font-medium">End</p>
|
||||
<TimePicker
|
||||
date={internalDateRange?.to}
|
||||
setDate={onEndTimeSelection}
|
||||
className="border-0 px-0 pt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={selectedOption} onValueChange={onDropDownSelection}>
|
||||
<SelectTrigger className="w-[120px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
<SelectItem
|
||||
key={DEFAULT_DATE_RANGE_SELECTION}
|
||||
value={DEFAULT_DATE_RANGE_SELECTION}
|
||||
>
|
||||
{DEFAULT_DATE_RANGE_SELECTION}
|
||||
</SelectItem>
|
||||
{dateTimeAggregationOptions.toReversed().map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DashboardDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react";
|
||||
import { addMinutes } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
|
||||
import {
|
||||
DEFAULT_AGGREGATION_SELECTION,
|
||||
tableDateRangeAggregationSettings,
|
||||
dashboardDateRangeAggregationSettings,
|
||||
DASHBOARD_AGGREGATION_PLACEHOLDER,
|
||||
type DashboardDateRangeOptions,
|
||||
type TableDateRangeOptions,
|
||||
DASHBOARD_AGGREGATION_OPTIONS,
|
||||
TABLE_AGGREGATION_OPTIONS,
|
||||
type DashboardDateRange,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
type BaseDateRangeDropdownProps<T> = {
|
||||
selectedOption: T;
|
||||
options: T[];
|
||||
onSelectionChange: (value: T) => void;
|
||||
};
|
||||
|
||||
const BaseDateRangeDropdown = <T extends string>({
|
||||
selectedOption,
|
||||
options,
|
||||
onSelectionChange,
|
||||
}: BaseDateRangeDropdownProps<T>) => {
|
||||
return (
|
||||
<Select value={selectedOption} onValueChange={onSelectionChange}>
|
||||
<SelectTrigger className="w-[130px] hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<Clock className="h-4 w-4" />
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
{options.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
type DashboardDateRangeDropdownProps = {
|
||||
selectedOption: DashboardDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: DashboardDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const DashboardDateRangeDropdown: React.FC<
|
||||
DashboardDateRangeDropdownProps
|
||||
> = ({ selectedOption, setDateRangeAndOption }) => {
|
||||
const onDropDownSelection = (value: DashboardDateRangeOptions) => {
|
||||
if (value === DASHBOARD_AGGREGATION_PLACEHOLDER) {
|
||||
setDateRangeAndOption(DASHBOARD_AGGREGATION_PLACEHOLDER, undefined);
|
||||
return;
|
||||
}
|
||||
const setting =
|
||||
dashboardDateRangeAggregationSettings[
|
||||
value as keyof typeof dashboardDateRangeAggregationSettings
|
||||
];
|
||||
setDateRangeAndOption(value, {
|
||||
from: addMinutes(new Date(), -setting.minutes),
|
||||
to: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const options =
|
||||
selectedOption === DASHBOARD_AGGREGATION_PLACEHOLDER
|
||||
? [...DASHBOARD_AGGREGATION_OPTIONS, DASHBOARD_AGGREGATION_PLACEHOLDER]
|
||||
: [...DASHBOARD_AGGREGATION_OPTIONS];
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={options}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TableDateRangeDropdownProps = {
|
||||
selectedOption: TableDateRangeOptions;
|
||||
setDateRangeAndOption: (
|
||||
option: TableDateRangeOptions,
|
||||
date?: DashboardDateRange,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const TableDateRangeDropdown: React.FC<TableDateRangeDropdownProps> = ({
|
||||
selectedOption,
|
||||
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(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
options={[...TABLE_AGGREGATION_OPTIONS, DEFAULT_AGGREGATION_SELECTION]}
|
||||
onSelectionChange={onDropDownSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -4,89 +4,61 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
export const GroupedScoreBadges = ({
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
import { type LastUserScore } from "@/src/features/scores/lib/types";
|
||||
|
||||
export const GroupedScoreBadges = <T extends APIScore | LastUserScore>({
|
||||
scores,
|
||||
variant = "badge",
|
||||
}: {
|
||||
scores: ScoreSimplified[];
|
||||
variant?: "badge" | "headings";
|
||||
scores: T[];
|
||||
}) => {
|
||||
const groupedScores = scores.reduce<Record<string, ScoreSimplified[]>>(
|
||||
(acc, score) => {
|
||||
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
|
||||
acc[score.name] = [score];
|
||||
} else {
|
||||
(acc[score.name] as ScoreSimplified[]).push(score);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const groupedScores = scores.reduce<Record<string, T[]>>((acc, score) => {
|
||||
if (!acc[score.name] || !Array.isArray(acc[score.name])) {
|
||||
acc[score.name] = [score];
|
||||
} else {
|
||||
acc[score.name].push(score);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const ScoresOfGroup = (props: {
|
||||
scores: ScoreSimplified[];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={cn("text-xs", props.className)}>
|
||||
{props.scores.map((s, i) => (
|
||||
<span key={i} className="group/score ml-1 first:ml-0">
|
||||
{isCategoricalDataType(s.dataType) || isBooleanDataType(s.dataType)
|
||||
? s.stringValue
|
||||
: s.value?.toFixed(2)}
|
||||
{s.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{s.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<span className="group-last/score:hidden">,</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "headings")
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<div key={name}>
|
||||
<div className="text-xs text-muted-foreground">{name}</div>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={name}
|
||||
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
|
||||
>
|
||||
<p className="truncate" title={name}>
|
||||
{name}:
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-nowrap">
|
||||
{scores.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="group/score ml-1 rounded-sm first:ml-0"
|
||||
>
|
||||
{s.stringValue ?? s.value?.toFixed(2) ?? ""}
|
||||
{s.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{s.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<span className="group-last/score:hidden">,</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={name}
|
||||
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
|
||||
>
|
||||
<p className="truncate" title={name}>
|
||||
{name}:
|
||||
</p>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,28 +5,14 @@ import {
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { HelpCircle, Info } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export type DocPopupProps = {
|
||||
description: React.ReactNode;
|
||||
href?: string;
|
||||
style?: "question" | "info";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
export default function DocPopup({
|
||||
description,
|
||||
href,
|
||||
style = "info",
|
||||
size = "sm",
|
||||
}: DocPopupProps) {
|
||||
const sizes = {
|
||||
xs: "w-3 h-3",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
export default function DocPopup({ description, href }: DocPopupProps) {
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
return (
|
||||
@@ -45,36 +31,21 @@ export default function DocPopup({
|
||||
className={cn("mx-1", href ? "cursor-pointer" : "cursor-default")}
|
||||
asChild
|
||||
>
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0"
|
||||
onClick={() => {
|
||||
capture("help_popup:href_clicked", {
|
||||
href: href,
|
||||
description: description,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{
|
||||
{
|
||||
question: <HelpCircle className={sizes[size]} />,
|
||||
info: <Info className={sizes[size]} />,
|
||||
}[style]
|
||||
}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0">
|
||||
{
|
||||
{
|
||||
question: <HelpCircle className={sizes[size]} />,
|
||||
info: <Info className={sizes[size]} />,
|
||||
}[style]
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0"
|
||||
onClick={(e) => {
|
||||
if (!href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(href, "_blank");
|
||||
capture("help_popup:href_clicked", {
|
||||
href: href,
|
||||
description: description,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Info className={"h-3 w-3"} />
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
{typeof description === "string" ? (
|
||||
|
||||
@@ -106,7 +106,6 @@ export default function Header({
|
||||
<DocPopup
|
||||
description={props.help.description}
|
||||
href={props.help.href}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
{props.featureBetaURL ? (
|
||||
|
||||
@@ -389,7 +389,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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 border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
|
||||
<span
|
||||
className="block max-w-56 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}
|
||||
</span>
|
||||
{userNavigation.map((item) => (
|
||||
@@ -454,7 +457,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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 border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
|
||||
<span
|
||||
className="mb-1 block max-w-56 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}
|
||||
</span>
|
||||
{userNavigation.map((item) => (
|
||||
|
||||
@@ -101,7 +101,6 @@ export const ROUTES: Route[] = [
|
||||
pathname: "/project/[projectId]/playground",
|
||||
icon: TerminalIcon,
|
||||
requires: "cloud-or-ee",
|
||||
label: "Beta",
|
||||
},
|
||||
{
|
||||
name: "Datasets",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import {
|
||||
type CategoricalAggregate,
|
||||
type NumericAggregate,
|
||||
} from "@/src/features/scores/lib/types";
|
||||
|
||||
import { numberFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
const COLOR_MAP = new Map([
|
||||
["True", "bg-light-green p-0.5 text-dark-green"],
|
||||
["False", "bg-light-red p-0.5 text-dark-red"],
|
||||
]);
|
||||
const COLLAPSE_CATEGORICAL_SCORES_AFTER = 2;
|
||||
|
||||
const ScoreValueCounts = ({
|
||||
valueCounts,
|
||||
}: {
|
||||
valueCounts: CategoricalAggregate["valueCounts"];
|
||||
}) => {
|
||||
return valueCounts.map(({ value, count }) => (
|
||||
<div key={value} className="flex flex-row">
|
||||
<span className="truncate">{value}</span>
|
||||
<span>{`: ${numberFormatter(count, 0)}`}</span>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export const ScoresTableCell = ({
|
||||
aggregate,
|
||||
showSingleValue = false,
|
||||
}: {
|
||||
aggregate: CategoricalAggregate | NumericAggregate;
|
||||
showSingleValue?: boolean;
|
||||
}) => {
|
||||
if (showSingleValue && aggregate.values.length === 1) {
|
||||
const value =
|
||||
aggregate.type === "NUMERIC"
|
||||
? aggregate.average.toFixed(4)
|
||||
: aggregate.values[0];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("flex flex-row gap-0.5 rounded-sm", COLOR_MAP.get(value))}
|
||||
>
|
||||
{value}
|
||||
{aggregate.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="inline-block cursor-pointer">
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{aggregate.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (aggregate.type === "NUMERIC") {
|
||||
return (
|
||||
<span className="rounded-sm">{`Ø ${aggregate.average.toFixed(4)}`}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
{aggregate.valueCounts.length > COLLAPSE_CATEGORICAL_SCORES_AFTER ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className="flex cursor-pointer flex-col group-hover:text-accent-dark-blue/55">
|
||||
<ScoreValueCounts
|
||||
valueCounts={aggregate.valueCounts.slice(
|
||||
0,
|
||||
COLLAPSE_CATEGORICAL_SCORES_AFTER,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="z-20 flex max-h-[40vh] max-w-64 flex-col overflow-y-auto whitespace-normal break-normal">
|
||||
<ScoreValueCounts valueCounts={aggregate.valueCounts} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<ScoreValueCounts valueCounts={aggregate.valueCounts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,8 +13,9 @@ import { api } from "@/src/utils/api";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
|
||||
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -47,6 +48,10 @@ export const SessionPage: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.isSuccess, session.data]);
|
||||
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
if (session.error?.data?.code === "UNAUTHORIZED")
|
||||
return <ErrorPage message="You do not have access to this session." />;
|
||||
|
||||
@@ -129,6 +134,8 @@ export const SessionPage: React.FC<{
|
||||
projectId={projectId}
|
||||
traceId={trace.id}
|
||||
scores={trace.scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
variant="badge"
|
||||
type="session"
|
||||
source="SessionDetail"
|
||||
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { type VisibilityState } from "@tanstack/react-table";
|
||||
import { ChevronDown, Columns } from "lucide-react";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
|
||||
interface DataTableColumnVisibilityFilterProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
@@ -22,6 +25,65 @@ interface DataTableColumnVisibilityFilterProps<TData, TValue> {
|
||||
setColumnVisibility: Dispatch<SetStateAction<VisibilityState>>;
|
||||
}
|
||||
|
||||
const calculateColumnCounts = <TData, TValue>(
|
||||
columns: LangfuseColumnDef<TData, TValue>[],
|
||||
columnVisibility: VisibilityState,
|
||||
) => {
|
||||
return columns.reduce(
|
||||
(acc, column) => {
|
||||
if (column.columns) {
|
||||
const groupCounts = calculateColumnCounts(
|
||||
column.columns,
|
||||
columnVisibility,
|
||||
);
|
||||
acc.count += groupCounts.count;
|
||||
acc.total += groupCounts.total;
|
||||
} else if (column.enableHiding) {
|
||||
acc.total++;
|
||||
if (
|
||||
column.accessorKey in columnVisibility &&
|
||||
columnVisibility[column.accessorKey]
|
||||
) {
|
||||
acc.count++;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ count: 0, total: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
function ColumnVisibilityDropdownItem<TData, TValue>({
|
||||
column,
|
||||
toggleColumn,
|
||||
columnVisibility,
|
||||
}: {
|
||||
column: LangfuseColumnDef<TData, TValue>;
|
||||
toggleColumn: (columnId: string) => void;
|
||||
columnVisibility: VisibilityState;
|
||||
}) {
|
||||
if (column.enableHiding) {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={columnVisibility[column.accessorKey]}
|
||||
onCheckedChange={() => toggleColumn(column.accessorKey)}
|
||||
>
|
||||
<span className="capitalize">
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</span>
|
||||
{column.headerTooltip && (
|
||||
<DocPopup
|
||||
description={column.headerTooltip.description}
|
||||
href={column.headerTooltip.href}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
columns,
|
||||
columnVisibility,
|
||||
@@ -49,27 +111,6 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
[setColumnVisibility],
|
||||
);
|
||||
|
||||
const calculateColumnCounts = (
|
||||
columns: LangfuseColumnDef<TData, TValue>[],
|
||||
columnVisibility: VisibilityState,
|
||||
) => {
|
||||
return columns.reduce(
|
||||
(acc, column) => {
|
||||
if (column.enableHiding) {
|
||||
acc.total++;
|
||||
if (
|
||||
column.accessorKey in columnVisibility &&
|
||||
columnVisibility[column.accessorKey]
|
||||
) {
|
||||
acc.count++;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ count: 0, total: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
const { count, total } = calculateColumnCounts(columns, columnVisibility);
|
||||
|
||||
return (
|
||||
@@ -92,22 +133,38 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
onPointerDownOutside={() => setIsOpen(false)}
|
||||
className="max-h-96 overflow-y-auto"
|
||||
>
|
||||
{columns.map(
|
||||
(column, index) =>
|
||||
"accessorKey" in column &&
|
||||
column.enableHiding && (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={index}
|
||||
className="capitalize"
|
||||
checked={columnVisibility[column.accessorKey]}
|
||||
onCheckedChange={() =>
|
||||
toggleColumn(column.accessorKey.toString())
|
||||
}
|
||||
>
|
||||
{column.header?.toString() ?? column.accessorKey.toString()}
|
||||
</DropdownMenuCheckboxItem>
|
||||
),
|
||||
)}
|
||||
{columns.map((column, index) => {
|
||||
if (!!column.columns && Boolean(column.columns.length)) {
|
||||
const isFollowingGroup = "columns" in (columns[index - 1] ?? {});
|
||||
return (
|
||||
<div key={index}>
|
||||
{!isFollowingGroup && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>
|
||||
{column.header && typeof column.header === "string"
|
||||
? column.header
|
||||
: column.accessorKey}
|
||||
</DropdownMenuLabel>
|
||||
{column.columns.map((column) => (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={column.accessorKey}
|
||||
column={column}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<ColumnVisibilityDropdownItem
|
||||
key={column.accessorKey}
|
||||
column={column}
|
||||
columnVisibility={columnVisibility}
|
||||
toggleColumn={toggleColumn}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "@/src/components/table/data-table-row-height-switch";
|
||||
import { Search } from "lucide-react";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { TableDateRangeDropdown } from "@/src/components/date-range-dropdowns";
|
||||
import {
|
||||
type TableDateRange,
|
||||
type TableDateRangeOptions,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
|
||||
interface SearchConfig {
|
||||
placeholder: string;
|
||||
@@ -32,6 +37,11 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
rowHeight?: RowHeight;
|
||||
setRowHeight?: Dispatch<SetStateAction<RowHeight>>;
|
||||
columnsWithCustomSelect?: string[];
|
||||
selectedOption?: TableDateRangeOptions;
|
||||
setDateRangeAndOption?: (
|
||||
option: TableDateRangeOptions,
|
||||
date?: TableDateRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData, TValue>({
|
||||
@@ -46,6 +56,8 @@ export function DataTableToolbar<TData, TValue>({
|
||||
rowHeight,
|
||||
setRowHeight,
|
||||
columnsWithCustomSelect,
|
||||
selectedOption,
|
||||
setDateRangeAndOption,
|
||||
}: DataTableToolbarProps<TData, TValue>) {
|
||||
const [searchString, setSearchString] = useState(
|
||||
searchConfig?.currentQuery ?? "",
|
||||
@@ -89,6 +101,12 @@ export function DataTableToolbar<TData, TValue>({
|
||||
columnsWithCustomSelect={columnsWithCustomSelect}
|
||||
/>
|
||||
)}
|
||||
{selectedOption && setDateRangeAndOption && (
|
||||
<TableDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row flex-wrap gap-2 pr-0.5 @6xl:ml-auto">
|
||||
{!!columnVisibility && !!setColumnVisibility && (
|
||||
<DataTableColumnVisibilityFilter
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { type OrderByState } from "@langfuse/shared";
|
||||
import React, { useState, useMemo } from "react";
|
||||
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { DataTablePagination } from "@/src/components/table/data-table-pagination";
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
@@ -52,6 +52,7 @@ interface DataTableProps<TData, TValue> {
|
||||
className?: string;
|
||||
paginationClassName?: string;
|
||||
isBorderless?: boolean;
|
||||
shouldRenderGroupHeaders?: boolean;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -76,6 +77,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
className,
|
||||
paginationClassName,
|
||||
isBorderless = false,
|
||||
shouldRenderGroupHeaders = false,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
@@ -106,8 +108,40 @@ export function DataTable<TData extends object, TValue>({
|
||||
rowSelection,
|
||||
},
|
||||
manualFiltering: true,
|
||||
defaultColumn: {
|
||||
minSize: 20,
|
||||
size: 150,
|
||||
maxSize: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
|
||||
// memo column sizes for performance
|
||||
// https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
|
||||
const columnSizeVars = useMemo(() => {
|
||||
const headers = table.getFlatHeaders();
|
||||
const colSizes: { [key: string]: number } = {};
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const header = headers[i]!;
|
||||
colSizes[`--header-${header.id}-size`] = header.getSize();
|
||||
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
|
||||
}
|
||||
return colSizes;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.getState().columnSizingInfo,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.getState().columnSizing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.getFlatHeaders(),
|
||||
columnVisibility,
|
||||
]);
|
||||
|
||||
const tableHeaders = shouldRenderGroupHeaders
|
||||
? table.getHeaderGroups()
|
||||
: [table.getHeaderGroups().slice(-1)[0]];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -121,10 +155,11 @@ export function DataTable<TData extends object, TValue>({
|
||||
"w-full overflow-auto",
|
||||
isBorderless ? "" : "rounded-md border",
|
||||
)}
|
||||
style={{ ...columnSizeVars }}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
{tableHeaders.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnDef = header.column
|
||||
@@ -134,9 +169,12 @@ export function DataTable<TData extends object, TValue>({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
sortingEnabled ? "cursor-pointer" : null,
|
||||
"whitespace-nowrap p-2",
|
||||
"group p-1 first:pl-2",
|
||||
sortingEnabled && "cursor-pointer",
|
||||
)}
|
||||
style={{
|
||||
width: `calc(var(--header-${header.id}-size) * 1px)`,
|
||||
}}
|
||||
title={sortingEnabled ? "Sort by this column" : ""}
|
||||
onClick={(event) => {
|
||||
event.preventDefault(); // Add this line
|
||||
@@ -181,20 +219,31 @@ export function DataTable<TData extends object, TValue>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
|
||||
{columnDef.headerTooltip && (
|
||||
<DocPopup
|
||||
description={
|
||||
columnDef.headerTooltip.description
|
||||
}
|
||||
href={columnDef.headerTooltip.href}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{orderBy?.column === columnDef.id
|
||||
? renderOrderingIndicator(orderBy)
|
||||
: null}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100",
|
||||
header.column.getIsResizing() &&
|
||||
"bg-primary-accent opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -204,54 +253,23 @@ export function DataTable<TData extends object, TValue>({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.isLoading || !data.data ? (
|
||||
<TableRow className="h-svh">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="content-start border-b text-center"
|
||||
>
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="overflow-hidden whitespace-nowrap border-b px-2 py-1 text-xs first:pl-2"
|
||||
>
|
||||
<div className={cn("flex items-center", rowheighttw)}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<div>
|
||||
No results.{" "}
|
||||
{help && (
|
||||
<DocPopup
|
||||
description={help.description}
|
||||
href={help.href}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoizedTableBody
|
||||
table={table}
|
||||
rowheighttw={rowheighttw}
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
/>
|
||||
) : (
|
||||
<TableBodyComponent
|
||||
table={table}
|
||||
rowheighttw={rowheighttw}
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<div className="grow"></div>
|
||||
@@ -274,7 +292,76 @@ export function DataTable<TData extends object, TValue>({
|
||||
}
|
||||
|
||||
function renderOrderingIndicator(orderBy?: OrderByState) {
|
||||
if (!orderBy) return;
|
||||
if (!orderBy) return null;
|
||||
if (orderBy.order === "ASC") return <span className="ml-1">▲</span>;
|
||||
else return <span className="ml-1">▼</span>;
|
||||
}
|
||||
|
||||
interface TableBodyComponentProps<TData> {
|
||||
table: ReturnType<typeof useReactTable<TData>>;
|
||||
rowheighttw?: string;
|
||||
columns: LangfuseColumnDef<TData, any>[];
|
||||
data: AsyncTableData<TData[]>;
|
||||
help?: { description: string; href: string };
|
||||
}
|
||||
|
||||
function TableBodyComponent<TData>({
|
||||
table,
|
||||
rowheighttw,
|
||||
columns,
|
||||
data,
|
||||
help,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
{data.isLoading || !data.data ? (
|
||||
<TableRow className="h-svh">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="content-start border-b text-center"
|
||||
>
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"overflow-hidden border-b p-1 text-xs first:pl-2",
|
||||
rowheighttw === "s" && "whitespace-nowrap",
|
||||
)}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex items-center", rowheighttw)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<div>
|
||||
No results.{" "}
|
||||
{help && (
|
||||
<DocPopup description={help.description} href={help.href} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
// memo tables for performance, should only re-render when data changes
|
||||
// https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
|
||||
const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
|
||||
return prev.table.options.data === next.table.options.data;
|
||||
}) as typeof TableBodyComponent;
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import Link from "next/link";
|
||||
|
||||
export type TableLinkProps = {
|
||||
path: string;
|
||||
value: string;
|
||||
truncateAt?: number;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function TableLink({
|
||||
path,
|
||||
value,
|
||||
truncateAt = 7,
|
||||
icon,
|
||||
className,
|
||||
}: TableLinkProps) {
|
||||
const truncatedValue =
|
||||
value.length - truncateAt > 3
|
||||
? `...${value.substring(value.length - truncateAt)}`
|
||||
: value;
|
||||
return (
|
||||
<Link
|
||||
className="inline-block 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"
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
href={path}
|
||||
title={value}
|
||||
>
|
||||
{icon ? icon : truncatedValue}
|
||||
{icon ? icon : value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,25 @@ export type TableRowOptions = {
|
||||
options: { label: string; value: number; icon?: LucideIcon }[];
|
||||
};
|
||||
|
||||
export type LangfuseColumnDef<
|
||||
TData extends RowData,
|
||||
TValue = unknown,
|
||||
> = ColumnDef<TData, TValue> & {
|
||||
accessorKey: string;
|
||||
// extends tanstack ColumnDef to include additional properties
|
||||
type ExtendedColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<
|
||||
TData,
|
||||
TValue
|
||||
> & {
|
||||
defaultHidden?: boolean;
|
||||
headerTooltip?: {
|
||||
description: string;
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// limits types of defined tanstack ColumnDef properties to specific subset of tanstack type union
|
||||
export type LangfuseColumnDef<
|
||||
TData extends RowData,
|
||||
TValue = unknown,
|
||||
> = ExtendedColumnDef<TData, TValue> & {
|
||||
// Enforce langfuse columns to be of type 'AccessorKeyColumnDefBase' with 'accessorKey' property of type string
|
||||
accessorKey: string;
|
||||
// Enforce langfuse group columns to have children of type 'LangfuseColumnDef'
|
||||
columns?: LangfuseColumnDef<TData, TValue>[];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { api, directApi } from "@/src/utils/api";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,10 +20,7 @@ import {
|
||||
withDefault,
|
||||
} from "use-query-params";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import {
|
||||
formatIntervalSeconds,
|
||||
localtimeDateOffsetByDays,
|
||||
} from "@/src/utils/dates";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import {
|
||||
@@ -42,11 +38,17 @@ import {
|
||||
} from "@langfuse/shared";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import type Decimal from "decimal.js";
|
||||
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
|
||||
import {
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
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";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -58,6 +60,8 @@ export type GenerationsTableRow = {
|
||||
completionStartTime?: Date;
|
||||
latency?: number;
|
||||
timeToFirstToken?: number;
|
||||
// scores holds grouped column with individual scores
|
||||
scores: ScoreAggregate;
|
||||
name?: string;
|
||||
model?: string;
|
||||
// i/o and metadata not set explicitly, but fetched from the server from the cell
|
||||
@@ -68,7 +72,6 @@ export type GenerationsTableRow = {
|
||||
outputCost?: Decimal;
|
||||
totalCost?: Decimal;
|
||||
traceName?: string;
|
||||
scores?: ScoreSimplified[];
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
@@ -109,15 +112,11 @@ export default function GenerationsTable({
|
||||
"s",
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
|
||||
const [inputFilterState, setInputFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "Start Time",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
|
||||
},
|
||||
],
|
||||
[],
|
||||
"generations",
|
||||
);
|
||||
|
||||
@@ -148,7 +147,19 @@ export default function GenerationsTable({
|
||||
]
|
||||
: [];
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "Start Time",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const filterState = inputFilterState.concat([
|
||||
...dateRangeFilter,
|
||||
...promptNameFilter,
|
||||
...promptVersionFilter,
|
||||
]);
|
||||
@@ -180,6 +191,13 @@ export default function GenerationsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
|
||||
useIndividualScoreColumns<GenerationsTableRow>({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedTimeOption: selectedOption,
|
||||
});
|
||||
|
||||
const transformFilterOptions = (
|
||||
filterOptions: ObservationOptions | undefined,
|
||||
) => {
|
||||
@@ -239,6 +257,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "ID",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const observationId = row.getValue("id");
|
||||
const traceId = row.getValue("traceId");
|
||||
@@ -256,12 +275,14 @@ export default function GenerationsTable({
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "Name",
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
header: "Trace ID",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("traceId");
|
||||
return typeof value === "string" ? (
|
||||
@@ -277,6 +298,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "traceName",
|
||||
id: "traceName",
|
||||
header: "Trace Name",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -284,6 +306,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "startTime",
|
||||
id: "startTime",
|
||||
header: "Start Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -295,6 +318,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "endTime",
|
||||
id: "endTime",
|
||||
header: "End Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -302,6 +326,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "timeToFirstToken",
|
||||
id: "timeToFirstToken",
|
||||
header: "Time to First Token",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -315,22 +340,12 @@ export default function GenerationsTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scores",
|
||||
id: "scores",
|
||||
header: "Scores",
|
||||
cell: ({ row }) => {
|
||||
const values: ScoreSimplified[] | undefined = row.getValue("scores");
|
||||
return (
|
||||
values && <GroupedScoreBadges scores={values} variant="headings" />
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
|
||||
{
|
||||
accessorKey: "latency",
|
||||
id: "latency",
|
||||
header: "Latency",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const latency: number | undefined = row.getValue("latency");
|
||||
return latency !== undefined ? (
|
||||
@@ -344,6 +359,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "timePerOutputToken",
|
||||
id: "timePerOutputToken",
|
||||
header: "Time per Output Token",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const latency: number | undefined = row.getValue("latency");
|
||||
const usage: {
|
||||
@@ -368,6 +384,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
header: "Input Cost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("inputCost");
|
||||
|
||||
@@ -383,6 +400,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "outputCost",
|
||||
id: "outputCost",
|
||||
header: "Output Cost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("outputCost");
|
||||
|
||||
@@ -398,6 +416,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "totalCost",
|
||||
header: "Total Cost",
|
||||
id: "totalCost",
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("totalCost");
|
||||
|
||||
@@ -412,6 +431,12 @@ export default function GenerationsTable({
|
||||
accessorKey: "level",
|
||||
id: "level",
|
||||
header: "Level",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Use You can differentiate the importance of observations with the level attribute to control the verbosity of your traces and highlight errors and warnings.",
|
||||
href: "https://langfuse.com/docs/tracing-features/log-levels",
|
||||
},
|
||||
enableHiding: true,
|
||||
cell({ row }) {
|
||||
const value: ObservationLevel | undefined = row.getValue("level");
|
||||
@@ -433,6 +458,12 @@ export default function GenerationsTable({
|
||||
accessorKey: "statusMessage",
|
||||
header: "Status Message",
|
||||
id: "statusMessage",
|
||||
size: 150,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Use a statusMessage to e.g. provide additional information on a status such as level=ERROR.",
|
||||
href: "https://langfuse.com/docs/tracing-features/log-levels",
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
@@ -440,6 +471,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "model",
|
||||
id: "model",
|
||||
header: "Model",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -447,6 +479,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
header: "Input Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -463,6 +496,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "outputTokens",
|
||||
id: "outputTokens",
|
||||
header: "Output Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -479,6 +513,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "totalTokens",
|
||||
id: "totalTokens",
|
||||
header: "Total Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -495,6 +530,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "usage",
|
||||
header: "Usage",
|
||||
id: "usage",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
@@ -517,6 +553,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
@@ -536,6 +573,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "output",
|
||||
id: "output",
|
||||
header: "Output",
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
@@ -554,6 +592,11 @@ export default function GenerationsTable({
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
size: 300,
|
||||
headerTooltip: {
|
||||
description: "Add metadata to traces to track additional information.",
|
||||
href: "https://langfuse.com/docs/tracing-features/metadata",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const observationId: string = row.getValue("id");
|
||||
const traceId: string = row.getValue("traceId");
|
||||
@@ -573,6 +616,11 @@ export default function GenerationsTable({
|
||||
accessorKey: "version",
|
||||
id: "version",
|
||||
header: "Version",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description: "Track changes via the version tag.",
|
||||
href: "https://langfuse.com/docs/experimentation",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -580,6 +628,11 @@ export default function GenerationsTable({
|
||||
accessorKey: "promptName",
|
||||
id: "promptName",
|
||||
header: "Prompt",
|
||||
headerTooltip: {
|
||||
description: "Link to prompt version in Langfuse prompt management.",
|
||||
href: "https://langfuse.com/docs/prompts",
|
||||
},
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -592,49 +645,54 @@ export default function GenerationsTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/prompts/${encodeURIComponent(promptName)}?version=${promptVersion}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibilityState] =
|
||||
useColumnVisibility<GenerationsTableRow>(
|
||||
"generationsColumnVisibility",
|
||||
`generationsColumnVisibility-${projectId}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
const rows: GenerationsTableRow[] = generations.isSuccess
|
||||
? generations.data.generations.map((generation) => {
|
||||
return {
|
||||
id: generation.id,
|
||||
traceId: generation.traceId ?? undefined,
|
||||
traceName: generation.traceName ?? "",
|
||||
startTime: generation.startTime,
|
||||
endTime: generation.endTime?.toLocaleString() ?? undefined,
|
||||
timeToFirstToken: generation.timeToFirstToken ?? undefined,
|
||||
latency: generation.latency ?? undefined,
|
||||
totalCost: generation.calculatedTotalCost ?? undefined,
|
||||
inputCost: generation.calculatedInputCost ?? undefined,
|
||||
outputCost: generation.calculatedOutputCost ?? undefined,
|
||||
name: generation.name ?? undefined,
|
||||
version: generation.version ?? "",
|
||||
model: generation.model ?? "",
|
||||
scores: generation.scores,
|
||||
level: generation.level,
|
||||
statusMessage: generation.statusMessage ?? undefined,
|
||||
usage: {
|
||||
promptTokens: generation.promptTokens,
|
||||
completionTokens: generation.completionTokens,
|
||||
totalTokens: generation.totalTokens,
|
||||
},
|
||||
promptId: generation.promptId ?? undefined,
|
||||
promptName: generation.promptName ?? undefined,
|
||||
promptVersion: generation.promptVersion ?? undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const rows: GenerationsTableRow[] = useMemo(() => {
|
||||
return generations.isSuccess
|
||||
? generations.data.generations.map((generation) => {
|
||||
return {
|
||||
id: generation.id,
|
||||
traceId: generation.traceId ?? undefined,
|
||||
traceName: generation.traceName ?? "",
|
||||
startTime: generation.startTime,
|
||||
endTime: generation.endTime?.toLocaleString() ?? undefined,
|
||||
timeToFirstToken: generation.timeToFirstToken ?? undefined,
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
generation.scores,
|
||||
),
|
||||
latency: generation.latency ?? undefined,
|
||||
totalCost: generation.calculatedTotalCost ?? undefined,
|
||||
inputCost: generation.calculatedInputCost ?? undefined,
|
||||
outputCost: generation.calculatedOutputCost ?? undefined,
|
||||
name: generation.name ?? undefined,
|
||||
version: generation.version ?? "",
|
||||
model: generation.model ?? "",
|
||||
level: generation.level,
|
||||
statusMessage: generation.statusMessage ?? undefined,
|
||||
usage: {
|
||||
promptTokens: generation.promptTokens,
|
||||
completionTokens: generation.completionTokens,
|
||||
totalTokens: generation.totalTokens,
|
||||
},
|
||||
promptId: generation.promptId ?? undefined,
|
||||
promptName: generation.promptName ?? undefined,
|
||||
promptVersion: generation.promptVersion ?? undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}, [generations, scoreKeysAndProps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -642,7 +700,7 @@ export default function GenerationsTable({
|
||||
columns={columns}
|
||||
filterColumnDefinition={transformFilterOptions(filterOptions.data)}
|
||||
filterState={inputFilterState}
|
||||
setFilterState={setInputFilterState}
|
||||
setFilterState={useDebounce(setInputFilterState)}
|
||||
searchConfig={{
|
||||
placeholder: "Search by id, name, traceName, model",
|
||||
updateQuery: setSearchQuery,
|
||||
@@ -652,6 +710,8 @@ export default function GenerationsTable({
|
||||
setColumnVisibility={setColumnVisibilityState}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
actionButtons={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -17,6 +17,9 @@ import Decimal from "decimal.js";
|
||||
import { Trash } from "lucide-react";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
|
||||
export type ModelTableRow = {
|
||||
modelId: string;
|
||||
@@ -62,12 +65,15 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
});
|
||||
const totalCount = models.data?.totalCount ?? 0;
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("models", "s");
|
||||
|
||||
const columns: LangfuseColumnDef<ModelTableRow>[] = [
|
||||
{
|
||||
accessorKey: "maintainer",
|
||||
id: "maintainer",
|
||||
enableColumnFilter: true,
|
||||
header: "Maintainer",
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "modelName",
|
||||
@@ -76,6 +82,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.modelName,
|
||||
},
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "startDate",
|
||||
@@ -84,6 +91,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.startDate,
|
||||
},
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const value: Date | undefined = row.getValue("startDate");
|
||||
|
||||
@@ -101,14 +109,13 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.matchPattern,
|
||||
},
|
||||
header: "Match Pattern",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const value: string = row.getValue("matchPattern");
|
||||
|
||||
return (
|
||||
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs ">
|
||||
{value}
|
||||
</code>
|
||||
);
|
||||
return value ? (
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -125,6 +132,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.inputPrice,
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("inputPrice");
|
||||
|
||||
@@ -136,6 +144,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "outputPrice",
|
||||
@@ -151,6 +160,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("outputPrice");
|
||||
|
||||
@@ -162,6 +172,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalPrice",
|
||||
@@ -177,6 +188,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
headerTooltip: {
|
||||
description: modelConfigDescriptions.totalPrice,
|
||||
},
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const value: Decimal | undefined = row.getValue("totalPrice");
|
||||
|
||||
@@ -188,6 +200,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "unit",
|
||||
@@ -197,6 +210,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.unit,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: "tokenizerId",
|
||||
@@ -206,6 +220,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.tokenizerId,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
@@ -215,19 +230,19 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
description: modelConfigDescriptions.config,
|
||||
},
|
||||
enableHiding: true,
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const value: Prisma.JsonValue | undefined = row.getValue("config");
|
||||
|
||||
return value ? (
|
||||
<span className="text-xs">{JSON.stringify(value)}</span>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
);
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<DeleteModelButton
|
||||
@@ -241,7 +256,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useColumnVisibility<ModelTableRow>("scoresColumnVisibility", columns);
|
||||
useColumnVisibility<ModelTableRow>("modelsColumnVisibility", columns);
|
||||
|
||||
const convertToTableRow = (model: Model): ModelTableRow => {
|
||||
return {
|
||||
@@ -262,31 +277,41 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
models.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: models.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: models.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: models.data.models.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
/>
|
||||
<>
|
||||
<DataTableToolbar
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
models.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: models.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: models.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: models.data.models.map((t) => convertToTableRow(t)),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
isNumericDataType,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
} from "@/src/features/scores/lib/helpers";
|
||||
import { Archive } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
@@ -107,6 +107,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
accessorKey: "dataType",
|
||||
id: "dataType",
|
||||
header: "Data Type",
|
||||
size: 80,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
@@ -114,6 +115,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
id: "range",
|
||||
header: "Range",
|
||||
enableHiding: true,
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const range = getConfigRange(row.original);
|
||||
|
||||
@@ -153,6 +155,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
accessorKey: "isArchived",
|
||||
id: "isArchived",
|
||||
header: "Status",
|
||||
size: 80,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const { isArchived } = row.original;
|
||||
@@ -162,6 +165,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
size: 70,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const { id: configId, isArchived, name } = row.original;
|
||||
|
||||
@@ -7,15 +7,15 @@ import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { isNumericDataType } from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { isNumericDataType } from "@/src/features/scores/lib/helpers";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import {
|
||||
type ScoreOptions,
|
||||
scoresTableColsWithOptions,
|
||||
} from "@/src/server/api/definitions/scoresTable";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { localtimeDateOffsetByDays } from "@/src/utils/dates";
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
import type { RouterOutput, RouterInput } from "@/src/utils/types";
|
||||
import type { FilterState, ScoreDataType } from "@langfuse/shared";
|
||||
@@ -84,20 +84,27 @@ export default function ScoresTable({
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("scores", "s");
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "Timestamp",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
|
||||
},
|
||||
],
|
||||
[],
|
||||
"scores",
|
||||
);
|
||||
|
||||
const filterState = createFilterState(userFilterState, [
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "Timestamp",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const combinedFilter = userFilterState.concat(dateRangeFilter);
|
||||
const filterState = createFilterState(combinedFilter, [
|
||||
...(userId ? [{ key: "User ID", value: userId }] : []),
|
||||
...(traceId ? [{ key: "Trace ID", value: traceId }] : []),
|
||||
...(observationId ? [{ key: "Observation ID", value: observationId }] : []),
|
||||
@@ -135,8 +142,9 @@ export default function ScoresTable({
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
enableColumnFilter: true,
|
||||
header: "Trace ID",
|
||||
header: "Trace",
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("traceId");
|
||||
return typeof value === "string" ? (
|
||||
@@ -152,8 +160,9 @@ export default function ScoresTable({
|
||||
{
|
||||
accessorKey: "observationId",
|
||||
id: "observationId",
|
||||
header: "Observation ID",
|
||||
header: "Observation",
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const observationId = row.getValue(
|
||||
"observationId",
|
||||
@@ -173,6 +182,7 @@ export default function ScoresTable({
|
||||
id: "traceName",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("traceName") as ScoresTableRow["traceName"];
|
||||
const filter = encodeURIComponent(
|
||||
@@ -182,14 +192,13 @@ export default function ScoresTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/traces?filter=${value ? filter : ""}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
header: "Trace User ID",
|
||||
header: "User",
|
||||
id: "userId",
|
||||
headerTooltip: {
|
||||
description: "The user ID associated with the trace.",
|
||||
@@ -197,6 +206,7 @@ export default function ScoresTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userId");
|
||||
return typeof value === "string" ? (
|
||||
@@ -204,7 +214,6 @@ export default function ScoresTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${value}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</>
|
||||
) : undefined;
|
||||
@@ -216,6 +225,7 @@ export default function ScoresTable({
|
||||
id: "timestamp",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
@@ -223,6 +233,7 @@ export default function ScoresTable({
|
||||
id: "source",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -230,6 +241,7 @@ export default function ScoresTable({
|
||||
id: "name",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "dataType",
|
||||
@@ -237,6 +249,7 @@ export default function ScoresTable({
|
||||
id: "dataType",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
@@ -244,12 +257,27 @@ export default function ScoresTable({
|
||||
id: "value",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: "comment",
|
||||
header: "Comment",
|
||||
id: "comment",
|
||||
enableHiding: true,
|
||||
size: 400,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("comment") as ScoresTableRow["comment"];
|
||||
return (
|
||||
!!value && <IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "author",
|
||||
id: "author",
|
||||
header: "Author",
|
||||
enableHiding: true,
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const { name, image } = row.getValue(
|
||||
"author",
|
||||
@@ -277,6 +305,7 @@ export default function ScoresTable({
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("jobConfigurationId");
|
||||
return typeof value === "string" ? (
|
||||
@@ -284,24 +313,11 @@ export default function ScoresTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/configs/${value}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</>
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "comment",
|
||||
header: "Comment",
|
||||
id: "comment",
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("comment") as ScoresTableRow["comment"];
|
||||
return (
|
||||
!!value && <IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns = rawColumns.filter(
|
||||
@@ -353,11 +369,13 @@ export default function ScoresTable({
|
||||
columns={columns}
|
||||
filterColumnDefinition={transformFilterOptions(filterOptions.data)}
|
||||
filterState={userFilterState}
|
||||
setFilterState={setUserFilterState}
|
||||
setFilterState={useDebounce(setUserFilterState)}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -14,17 +14,15 @@ import {
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
formatIntervalSeconds,
|
||||
localtimeDateOffsetByDays,
|
||||
} from "@/src/utils/dates";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import type Decimal from "decimal.js";
|
||||
import { useEffect } from "react";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
|
||||
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
|
||||
export type SessionTableRow = {
|
||||
id: string;
|
||||
@@ -53,16 +51,11 @@ export default function SessionsTable({
|
||||
omittedFilter = [],
|
||||
}: SessionTableProps) {
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "Created At",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
|
||||
},
|
||||
],
|
||||
[],
|
||||
"sessions",
|
||||
);
|
||||
|
||||
@@ -77,7 +70,18 @@ export default function SessionsTable({
|
||||
]
|
||||
: [];
|
||||
|
||||
const filterState = userFilterState.concat(userIdFilter);
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "createdAt",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const filterState = userFilterState.concat(userIdFilter, dateRangeFilter);
|
||||
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
@@ -145,6 +149,7 @@ export default function SessionsTable({
|
||||
accessorKey: "bookmarked",
|
||||
id: "bookmarked",
|
||||
header: undefined,
|
||||
size: 50,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked = row.getValue("bookmarked");
|
||||
const sessionId = row.getValue("id");
|
||||
@@ -165,13 +170,13 @@ export default function SessionsTable({
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "ID",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("id");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -181,6 +186,7 @@ export default function SessionsTable({
|
||||
accessorKey: "createdAt",
|
||||
id: "createdAt",
|
||||
header: "Created At",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -188,6 +194,7 @@ export default function SessionsTable({
|
||||
accessorKey: "sessionDuration",
|
||||
id: "sessionDuration",
|
||||
header: "Duration",
|
||||
size: 130,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("sessionDuration");
|
||||
@@ -202,6 +209,7 @@ export default function SessionsTable({
|
||||
enableColumnFilter: !omittedFilter.find((f) => f === "userIds"),
|
||||
id: "userIds",
|
||||
header: "User IDs",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userIds");
|
||||
@@ -212,7 +220,6 @@ export default function SessionsTable({
|
||||
key={user}
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(user)}`}
|
||||
value={user}
|
||||
truncateAt={40}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,7 +229,11 @@ export default function SessionsTable({
|
||||
{
|
||||
accessorKey: "countTraces",
|
||||
id: "countTraces",
|
||||
header: "Traces Count",
|
||||
header: "Traces",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description: "The number of traces in the session.",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -230,6 +241,7 @@ export default function SessionsTable({
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
header: "Input Cost",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -244,6 +256,7 @@ export default function SessionsTable({
|
||||
accessorKey: "outputCost",
|
||||
id: "outputCost",
|
||||
header: "Output Cost",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
@@ -259,6 +272,7 @@ export default function SessionsTable({
|
||||
accessorKey: "totalCost",
|
||||
id: "totalCost",
|
||||
header: "Total Cost",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -273,6 +287,7 @@ export default function SessionsTable({
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
header: "Input Tokens",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -288,6 +303,7 @@ export default function SessionsTable({
|
||||
accessorKey: "outputTokens",
|
||||
id: "outputTokens",
|
||||
header: "Output Tokens",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -303,6 +319,7 @@ export default function SessionsTable({
|
||||
accessorKey: "totalTokens",
|
||||
id: "totalTokens",
|
||||
header: "Total Tokens",
|
||||
size: 110,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -317,6 +334,7 @@ export default function SessionsTable({
|
||||
accessorKey: "usage",
|
||||
id: "usage",
|
||||
header: "Usage",
|
||||
size: 220,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -349,7 +367,7 @@ export default function SessionsTable({
|
||||
<DataTableToolbar
|
||||
filterColumnDefinition={transformFilterOptions()}
|
||||
filterState={userFilterState}
|
||||
setFilterState={setUserFilterState}
|
||||
setFilterState={useDebounce(setUserFilterState)}
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
@@ -360,6 +378,8 @@ export default function SessionsTable({
|
||||
key="batchExport"
|
||||
/>,
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
columnsWithCustomSelect={["userIds"]}
|
||||
/>
|
||||
<DataTable
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { StarTraceToggle } from "@/src/components/star-toggle";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { TraceTableMultiSelectAction } from "@/src/components/table/data-table-multi-select-actions/trace-table-multi-select-action";
|
||||
@@ -11,13 +10,10 @@ import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
formatIntervalSeconds,
|
||||
localtimeDateOffsetByDays,
|
||||
} from "@/src/utils/dates";
|
||||
import { type RouterInput, type RouterOutput } from "@/src/utils/types";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { type RouterInput } from "@/src/utils/types";
|
||||
import { type RowSelectionState } from "@tanstack/react-table";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
@@ -40,8 +36,14 @@ import {
|
||||
} from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { useTableLookBackDays } from "@/src/hooks/useTableLookBackDays";
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
import {
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
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";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -51,6 +53,8 @@ export type TracesTableRow = {
|
||||
userId: string;
|
||||
level: ObservationLevel;
|
||||
observationCount: number;
|
||||
// scores holds grouped column with individual scores
|
||||
scores: ScoreAggregate;
|
||||
latency?: number;
|
||||
release?: string;
|
||||
version?: string;
|
||||
@@ -59,7 +63,6 @@ export type TracesTableRow = {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
metadata?: unknown;
|
||||
scores: APIScore[];
|
||||
tags: string[];
|
||||
usage: {
|
||||
promptTokens: bigint;
|
||||
@@ -92,15 +95,10 @@ export default function TracesTable({
|
||||
withDefault(StringParam, null),
|
||||
);
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange();
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "Timestamp",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: localtimeDateOffsetByDays(-useTableLookBackDays(projectId)),
|
||||
},
|
||||
],
|
||||
[],
|
||||
"traces",
|
||||
);
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
@@ -108,6 +106,16 @@ export default function TracesTable({
|
||||
order: "DESC",
|
||||
});
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "Timestamp",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const userIdFilter: FilterState = userId
|
||||
? [
|
||||
{
|
||||
@@ -119,11 +127,12 @@ export default function TracesTable({
|
||||
]
|
||||
: [];
|
||||
|
||||
const filterState = userFilterState.concat(userIdFilter);
|
||||
const filterState = userFilterState.concat(userIdFilter, dateRangeFilter);
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const tracesAllQueryFilter = {
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
@@ -148,12 +157,13 @@ export default function TracesTable({
|
||||
// loading filter options individually from the remaining calls
|
||||
// traces.all should load first together with everything else.
|
||||
// This here happens in the background.
|
||||
const timestampFilter = filterState.find((f) => f.column === "Timestamp");
|
||||
const traceFilterOptions = api.traces.filterOptions.useQuery(
|
||||
{
|
||||
projectId,
|
||||
timestampFilter:
|
||||
timestampFilter?.type === "datetime" ? timestampFilter : undefined,
|
||||
dateRangeFilter[0]?.type === "datetime"
|
||||
? dateRangeFilter[0]
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
@@ -172,40 +182,19 @@ export default function TracesTable({
|
||||
);
|
||||
};
|
||||
|
||||
const convertToTableRow = (
|
||||
trace: RouterOutput["traces"]["all"]["traces"][0],
|
||||
): TracesTableRow => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
id: trace.id,
|
||||
timestamp: trace.timestamp.toLocaleString(),
|
||||
name: trace.name ?? "",
|
||||
level: trace.level,
|
||||
observationCount: trace.observationCount,
|
||||
release: trace.release ?? undefined,
|
||||
version: trace.version ?? undefined,
|
||||
userId: trace.userId ?? "",
|
||||
scores: trace.scores,
|
||||
sessionId: trace.sessionId ?? undefined,
|
||||
latency: trace.latency === null ? undefined : trace.latency,
|
||||
tags: trace.tags,
|
||||
usage: {
|
||||
promptTokens: trace.promptTokens,
|
||||
completionTokens: trace.completionTokens,
|
||||
totalTokens: trace.totalTokens,
|
||||
},
|
||||
inputCost: trace.calculatedInputCost ?? undefined,
|
||||
outputCost: trace.calculatedOutputCost ?? undefined,
|
||||
totalCost: trace.calculatedTotalCost ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("traces", "s");
|
||||
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
|
||||
useIndividualScoreColumns<TracesTableRow>({
|
||||
projectId,
|
||||
scoreColumnKey: "scores",
|
||||
selectedTimeOption: selectedOption,
|
||||
});
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
{
|
||||
id: "select",
|
||||
accessorKey: "select",
|
||||
size: 30,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -238,6 +227,7 @@ export default function TracesTable({
|
||||
accessorKey: "bookmarked",
|
||||
header: undefined,
|
||||
id: "bookmarked",
|
||||
size: 30,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked: TracesTableRow["bookmarked"] =
|
||||
row.getValue("bookmarked");
|
||||
@@ -259,6 +249,7 @@ export default function TracesTable({
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
id: "id",
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["id"] = row.getValue("id");
|
||||
return value && typeof value === "string" ? (
|
||||
@@ -274,6 +265,7 @@ export default function TracesTable({
|
||||
accessorKey: "timestamp",
|
||||
header: "Timestamp",
|
||||
id: "timestamp",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -281,20 +273,25 @@ export default function TracesTable({
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
id: "name",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
header: "User ID",
|
||||
header: "User",
|
||||
id: "userId",
|
||||
size: 150,
|
||||
headerTooltip: {
|
||||
description: "Add `userId` to traces to track users.",
|
||||
href: "https://langfuse.com/docs/tracing-features/users",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["userId"] = row.getValue("userId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -305,14 +302,18 @@ export default function TracesTable({
|
||||
accessorKey: "sessionId",
|
||||
enableColumnFilter: !omittedFilter.find((f) => f === "sessionId"),
|
||||
id: "sessionId",
|
||||
header: "Session ID",
|
||||
header: "Session",
|
||||
size: 150,
|
||||
headerTooltip: {
|
||||
description: "Add `sessionId` to traces to track sessions.",
|
||||
href: "https://langfuse.com/docs/tracing-features/sessions",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["sessionId"] = row.getValue("sessionId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -323,6 +324,7 @@ export default function TracesTable({
|
||||
accessorKey: "latency",
|
||||
id: "latency",
|
||||
header: "Latency",
|
||||
size: 70,
|
||||
// add seconds to the end of the latency
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["latency"] = row.getValue("latency");
|
||||
@@ -335,6 +337,7 @@ export default function TracesTable({
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
header: "Input Tokens",
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.promptTokens, 0)}</span>;
|
||||
@@ -347,6 +350,7 @@ export default function TracesTable({
|
||||
accessorKey: "outputTokens",
|
||||
id: "outputTokens",
|
||||
header: "Output Tokens",
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.completionTokens, 0)}</span>;
|
||||
@@ -359,6 +363,7 @@ export default function TracesTable({
|
||||
accessorKey: "totalTokens",
|
||||
id: "totalTokens",
|
||||
header: "Total Tokens",
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
return <span>{numberFormatter(value.totalTokens, 0)}</span>;
|
||||
@@ -368,10 +373,10 @@ export default function TracesTable({
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
// TODO: Enable Ordering By Usage (not covered by API yet)
|
||||
accessorKey: "usage",
|
||||
header: "Usage",
|
||||
id: "usage",
|
||||
size: 220,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["usage"] = row.getValue("usage");
|
||||
return (
|
||||
@@ -386,10 +391,12 @@ export default function TracesTable({
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{ ...getScoreGroupColumnProps(isColumnLoading), columns: scoreColumns },
|
||||
{
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
header: "Input Cost",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
|
||||
return (
|
||||
@@ -410,6 +417,7 @@ export default function TracesTable({
|
||||
accessorKey: "outputCost",
|
||||
id: "outputCost",
|
||||
header: "Output Cost",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["outputCost"] = row.getValue("outputCost");
|
||||
return (
|
||||
@@ -430,6 +438,7 @@ export default function TracesTable({
|
||||
accessorKey: "totalCost",
|
||||
id: "totalCost",
|
||||
header: "Total Cost",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
|
||||
return (
|
||||
@@ -445,21 +454,11 @@ export default function TracesTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "scores",
|
||||
id: "scores",
|
||||
header: "Scores",
|
||||
enableColumnFilter: !omittedFilter.find((f) => f === "scores"),
|
||||
cell: ({ row }) => {
|
||||
const values: TracesTableRow["scores"] = row.getValue("scores");
|
||||
return <GroupedScoreBadges scores={values} variant="headings" />;
|
||||
},
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 400,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return (
|
||||
@@ -477,6 +476,7 @@ export default function TracesTable({
|
||||
accessorKey: "output",
|
||||
header: "Output",
|
||||
id: "output",
|
||||
size: 400,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return (
|
||||
@@ -493,6 +493,11 @@ export default function TracesTable({
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
size: 400,
|
||||
headerTooltip: {
|
||||
description: "Add metadata to traces to track additional information.",
|
||||
href: "https://langfuse.com/docs/tracing-features/metadata",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return (
|
||||
@@ -510,6 +515,7 @@ export default function TracesTable({
|
||||
accessorKey: "level",
|
||||
id: "level",
|
||||
header: "Level",
|
||||
size: 75,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["level"] = row.getValue("level");
|
||||
return (
|
||||
@@ -531,7 +537,11 @@ export default function TracesTable({
|
||||
{
|
||||
accessorKey: "observationCount",
|
||||
id: "observationCount",
|
||||
header: "Observation Count",
|
||||
header: "Observations",
|
||||
size: 120,
|
||||
headerTooltip: {
|
||||
description: "The number of observations in the trace.",
|
||||
},
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
@@ -539,6 +549,11 @@ export default function TracesTable({
|
||||
accessorKey: "version",
|
||||
id: "version",
|
||||
header: "Version",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description: "Track changes via the version tag.",
|
||||
href: "https://langfuse.com/docs/experimentation",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
@@ -547,6 +562,11 @@ export default function TracesTable({
|
||||
accessorKey: "release",
|
||||
id: "release",
|
||||
header: "Release",
|
||||
size: 100,
|
||||
headerTooltip: {
|
||||
description: "Track changes to your application via the release tag.",
|
||||
href: "https://langfuse.com/docs/experimentation",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
@@ -555,6 +575,11 @@ export default function TracesTable({
|
||||
accessorKey: "tags",
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
size: 150,
|
||||
headerTooltip: {
|
||||
description: "Group traces with tags.",
|
||||
href: "https://langfuse.com/docs/tracing-features/tags",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const tags: TracesTableRow["tags"] = row.getValue("tags");
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
@@ -567,6 +592,7 @@ export default function TracesTable({
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
tracesFilter={tracesAllQueryFilter}
|
||||
className={cn(rowHeight !== "s" && "flex-wrap")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -575,6 +601,7 @@ export default function TracesTable({
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return traceId && typeof traceId === "string" ? (
|
||||
@@ -592,7 +619,43 @@ export default function TracesTable({
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useColumnVisibility<TracesTableRow>("tracesColumnVisibility", columns);
|
||||
useColumnVisibility<TracesTableRow>(
|
||||
`tracesColumnVisibility-${projectId}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return traces.isSuccess
|
||||
? traces.data.traces.map((trace) => {
|
||||
return {
|
||||
bookmarked: trace.bookmarked,
|
||||
id: trace.id,
|
||||
timestamp: trace.timestamp.toLocaleString(),
|
||||
name: trace.name ?? "",
|
||||
level: trace.level,
|
||||
observationCount: trace.observationCount,
|
||||
release: trace.release ?? undefined,
|
||||
version: trace.version ?? undefined,
|
||||
userId: trace.userId ?? "",
|
||||
sessionId: trace.sessionId ?? undefined,
|
||||
latency: trace.latency === null ? undefined : trace.latency,
|
||||
tags: trace.tags,
|
||||
usage: {
|
||||
promptTokens: trace.promptTokens,
|
||||
completionTokens: trace.completionTokens,
|
||||
totalTokens: trace.totalTokens,
|
||||
},
|
||||
scores: verifyAndPrefixScoreDataAgainstKeys(
|
||||
scoreKeysAndProps,
|
||||
trace.scores,
|
||||
),
|
||||
inputCost: trace.calculatedInputCost ?? undefined,
|
||||
outputCost: trace.calculatedOutputCost ?? undefined,
|
||||
totalCost: trace.calculatedTotalCost ?? undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
}, [traces, scoreKeysAndProps]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -605,7 +668,7 @@ export default function TracesTable({
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
filterState={userFilterState}
|
||||
setFilterState={setUserFilterState}
|
||||
setFilterState={useDebounce(setUserFilterState)}
|
||||
actionButtons={
|
||||
Object.keys(selectedRows).filter((traceId) =>
|
||||
traces.data?.traces.map((t) => t.id).includes(traceId),
|
||||
@@ -626,6 +689,8 @@ export default function TracesTable({
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -641,7 +706,7 @@ export default function TracesTable({
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: traces.data.traces.map((t) => convertToTableRow(t)),
|
||||
data: rows,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -21,8 +21,9 @@ import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
|
||||
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
|
||||
export const ObservationPreview = (props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -35,6 +36,9 @@ export const ObservationPreview = (props: {
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const observationWithInputAndOutput = api.observations.byId.useQuery({
|
||||
observationId: props.currentObservationId,
|
||||
@@ -164,6 +168,8 @@ export const ObservationPreview = (props: {
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={props.scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
type="observation"
|
||||
key={"annotation-drawer" + preloadedObservation.id}
|
||||
/>
|
||||
|
||||
@@ -18,8 +18,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { AnnotateDrawer } from "@/src/features/manual-scoring/components/AnnotateDrawer";
|
||||
import { AnnotateDrawer } from "@/src/features/scores/components/AnnotateDrawer";
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
|
||||
export const TracePreview = ({
|
||||
trace,
|
||||
@@ -34,6 +35,9 @@ export const TracePreview = ({
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const traceScores = scores.filter((s) => s.observationId === null);
|
||||
const traceScoresBySource = traceScores.reduce((acc, score) => {
|
||||
@@ -100,6 +104,8 @@ export const TracePreview = ({
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
key={"annotation-drawer" + trace.id}
|
||||
/>
|
||||
<NewDatasetItemFromTrace
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
ChevronsUpDown,
|
||||
ListTree,
|
||||
Network,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import Decimal from "decimal.js";
|
||||
@@ -33,7 +32,6 @@ import { DeleteButton } from "@/src/components/deleteButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { TraceTimelineView } from "@/src/components/trace/TraceTimelineView";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/src/components/ui/alert";
|
||||
import { type APIScore } from "@/src/features/public-api/types/scores";
|
||||
|
||||
export function Trace(props: {
|
||||
@@ -295,9 +293,9 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
</Badge>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border bg-card font-semibold text-card-foreground shadow-sm">
|
||||
<div className="flex flex-row items-center gap-3 p-2.5">
|
||||
Tags
|
||||
<div className="mt-3 rounded-lg border bg-card font-semibold text-card-foreground">
|
||||
<div className="flex flex-row items-center gap-3 px-3 py-1">
|
||||
<span className="text-sm">Tags</span>
|
||||
<TagTraceDetailsPopover
|
||||
tags={trace.data.tags}
|
||||
availableTags={allTags}
|
||||
@@ -329,7 +327,6 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
>
|
||||
<ListTree className="mr-1 h-4 w-4"></ListTree>
|
||||
Timeline
|
||||
<Badge className="pointer-events-none ml-2 px-1.5">Beta</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -346,22 +343,6 @@ export function TracePage({ traceId }: { traceId: string }) {
|
||||
)}
|
||||
{selectedTab === "timeline" && (
|
||||
<div className="mt-5 flex-1 flex-col space-y-5 overflow-hidden">
|
||||
<Alert>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle>New Trace Timeline (beta)</AlertTitle>
|
||||
<AlertDescription>
|
||||
We value your feedback! Share your thoughts on{" "}
|
||||
<a
|
||||
href="https://github.com/orgs/langfuse/discussions/2195"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub discussions
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TraceTimelineView
|
||||
key={trace.data.id}
|
||||
trace={trace.data}
|
||||
|
||||
@@ -191,11 +191,11 @@ export const IOTableCell = ({
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<JsonSkeleton className="h-full w-[400px] overflow-hidden px-2 py-1" />
|
||||
<JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />
|
||||
) : singleLine ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-[400px] self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
|
||||
"h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -205,7 +205,7 @@ export const IOTableCell = ({
|
||||
<JSONView
|
||||
json={stringifyJsonNode(data)}
|
||||
className={cn(
|
||||
"h-full w-[400px] self-stretch overflow-y-auto rounded-sm ",
|
||||
"h-full w-full self-stretch overflow-y-auto rounded-sm ",
|
||||
className,
|
||||
)}
|
||||
codeClassName="py-1 px-2"
|
||||
|
||||
@@ -44,8 +44,16 @@ const isChecklist = (children: ReactNode) =>
|
||||
* Security risks are taken care of by a validation in api.utilities.validateImgUrl
|
||||
* Fetching image will fail if SSL/TLS certificate is invalid or expired, will be handled by onError
|
||||
* Do not use this customLoader in production if you are not using the above mentioned security measures */
|
||||
const customLoader = ({ src }: { src: string }) => {
|
||||
return src;
|
||||
const customLoader = ({
|
||||
src,
|
||||
width,
|
||||
quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
}) => {
|
||||
return `${src}?w=${width}&q=${quality || 75}`;
|
||||
};
|
||||
|
||||
const ImageErrorDisplay = ({
|
||||
@@ -80,8 +88,8 @@ const MarkdownImage: Components["img"] = ({ src, alt }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const isHttp = new URL(src).protocol === "http:";
|
||||
const errorDescription = `Cannot load image. ${isHttp ? "Http images are not rendered in Langfuse for security reasons." : ""} Click to view image in new tab`;
|
||||
const errorDescription =
|
||||
"Cannot load image. Http images are not rendered in Langfuse for security reasons";
|
||||
|
||||
if (isValidImage.data?.isValid) {
|
||||
return (
|
||||
@@ -240,11 +248,11 @@ export function MarkdownView({
|
||||
if (isChecklist(children))
|
||||
return <ul className="list-none">{children}</ul>;
|
||||
|
||||
return <ul className="list-inside list-disc pl-2">{children}</ul>;
|
||||
return <ul className="list-outside list-disc pl-4">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol className="list-inside list-decimal pl-2">{children}</ol>
|
||||
<ol className="list-outside list-decimal pl-4">{children}</ol>
|
||||
);
|
||||
},
|
||||
li({ children }) {
|
||||
@@ -310,7 +318,7 @@ export function MarkdownView({
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded border">
|
||||
<div className="overflow-x-auto rounded border text-xs">
|
||||
<table className="min-w-full divide-y">{children}</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ const Table = React.forwardRef<
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full table-auto caption-bottom border-separate border-spacing-0 space-y-4 overflow-auto text-sm",
|
||||
"w-full table-fixed caption-bottom border-separate border-spacing-0 space-y-4 overflow-auto text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 z-10 rounded-t-md border-b bg-background px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"sticky top-0 z-10 border-b bg-background px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -88,10 +88,7 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"whitespace-nowrap p-4 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -201,3 +201,18 @@ export function display12HourValue(hours: number) {
|
||||
if (hours % 12 > 9) return `${hours}`;
|
||||
return `0${hours % 12}`;
|
||||
}
|
||||
|
||||
export function combineDateAndTime(
|
||||
date: Date | undefined,
|
||||
time: Date | undefined,
|
||||
) {
|
||||
if (!date || !time) return undefined;
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
time.getHours(),
|
||||
time.getMinutes(),
|
||||
time.getSeconds(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import { TimePeriodSelect } from "./time-period-select";
|
||||
import { type Period } from "./time-picker-utils";
|
||||
import { getTimezoneDetails, getShortLocalTimezone } from "@/src/utils/dates";
|
||||
import { TimeIcon } from "@/src/components/ui/time-icon";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
interface TimePickerProps {
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TimePicker({ date, setDate }: TimePickerProps) {
|
||||
const [period, setPeriod] = React.useState<Period>("AM");
|
||||
|
||||
export function TimePicker({ date, setDate, className }: TimePickerProps) {
|
||||
const getInitialPeriod = (date: Date | undefined): Period => {
|
||||
if (!date) return "AM";
|
||||
return date.getHours() >= 12 ? "PM" : "AM";
|
||||
};
|
||||
const [period, setPeriod] = React.useState<Period>(getInitialPeriod(date));
|
||||
const minuteRef = React.useRef<HTMLInputElement>(null);
|
||||
const hourRef = React.useRef<HTMLInputElement>(null);
|
||||
const secondRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -24,7 +29,12 @@ export function TimePicker({ date, setDate }: TimePickerProps) {
|
||||
const timezoneDetails = React.useMemo(() => getTimezoneDetails(), []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 items-center gap-1 rounded-b-md border-t-2 bg-transparent px-3 py-2 text-sm ring-offset-background">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-1 items-center gap-1 rounded-b-md border-t-2 bg-transparent px-3 py-2 text-sm ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-1 grid gap-1 text-center">
|
||||
<TimeIcon time={date ?? period} />
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.65.0";
|
||||
export const VERSION = "v2.69.0";
|
||||
|
||||
@@ -93,7 +93,6 @@ export const EvalConfigDetail = () => {
|
||||
`${existingEvalConfig.evalTemplate.name} (v${existingEvalConfig.evalTemplate.version})` ??
|
||||
""
|
||||
}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user