Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+12
-3
@@ -44,6 +44,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 +105,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
|
||||
|
||||
|
||||
@@ -151,5 +149,16 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.65.0",
|
||||
"version": "2.67.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@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,6 +56,8 @@
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.4.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"kysely": "^0.27.3",
|
||||
"langchain": "^0.2.6",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
@@ -2,8 +2,12 @@ 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 "./redis/redis";
|
||||
export * from "./redis/trace-upsert";
|
||||
export * from "./clickhouse";
|
||||
|
||||
@@ -45,6 +45,7 @@ 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-05-13",
|
||||
@@ -66,6 +67,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",
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+36
-22
@@ -40,7 +40,7 @@ importers:
|
||||
version: 14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-auth:
|
||||
specifier: ^4.24.7
|
||||
version: 4.24.7(next@14.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
version: 4.24.7(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
@@ -122,6 +122,9 @@ importers:
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.554.0
|
||||
version: 3.554.0
|
||||
'@clickhouse/client':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@langchain/anthropic':
|
||||
specifier: ^0.1.21
|
||||
version: 0.1.21
|
||||
@@ -149,6 +152,12 @@ importers:
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
bullmq:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.3
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1
|
||||
kysely:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
@@ -417,6 +426,9 @@ importers:
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
bullmq:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.3
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -447,6 +459,9 @@ importers:
|
||||
graphql:
|
||||
specifier: ^16.9.0
|
||||
version: 16.9.0
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1
|
||||
ip-address:
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
@@ -470,7 +485,7 @@ importers:
|
||||
version: 14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-auth:
|
||||
specifier: ^4.24.7
|
||||
version: 4.24.7(next@14.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
version: 4.24.7(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-query-params:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
|
||||
@@ -659,6 +674,9 @@ importers:
|
||||
'@anthropic-ai/tokenizer':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
'@clickhouse/client':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@langfuse/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/shared
|
||||
@@ -1279,6 +1297,13 @@ packages:
|
||||
'@chevrotain/utils@10.5.0':
|
||||
resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==}
|
||||
|
||||
'@clickhouse/client-common@1.4.0':
|
||||
resolution: {integrity: sha512-kglG8YyWnR1K24RckUf5ZdNTN0U0s+a1j/bpCO4ZjzjO87ICgWlXFVD22pZqSACW7/2IIi1IkzbwtxKI2s/MOw==}
|
||||
|
||||
'@clickhouse/client@1.4.0':
|
||||
resolution: {integrity: sha512-O4mbFPM/wQtFck01ghYI2mnNHv9jSFEiQBsTCH4t6MKeGHNAPkJGaFGv+KycLTv6zjnQNjiUGdXDMVRema5SyA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
'@codemirror/autocomplete@6.17.0':
|
||||
resolution: {integrity: sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==}
|
||||
peerDependencies:
|
||||
@@ -11553,6 +11578,12 @@ snapshots:
|
||||
|
||||
'@chevrotain/utils@10.5.0': {}
|
||||
|
||||
'@clickhouse/client-common@1.4.0': {}
|
||||
|
||||
'@clickhouse/client@1.4.0':
|
||||
dependencies:
|
||||
'@clickhouse/client-common': 1.4.0
|
||||
|
||||
'@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.4)(@lezer/common@1.2.1)':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.10.2
|
||||
@@ -15290,7 +15321,7 @@ snapshots:
|
||||
'@typescript-eslint/eslint-plugin': 7.3.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
|
||||
'@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5)
|
||||
eslint-config-prettier: 9.1.0(eslint@8.57.0)
|
||||
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))
|
||||
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0))
|
||||
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
|
||||
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
@@ -16999,7 +17030,7 @@ snapshots:
|
||||
eslint: 8.57.0
|
||||
eslint-plugin-turbo: 1.13.4(eslint@8.57.0)
|
||||
|
||||
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)):
|
||||
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)):
|
||||
dependencies:
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
||||
|
||||
@@ -18134,7 +18165,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.2.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5(supports-color@5.5.0)
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
@@ -19808,23 +19839,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
nodemailer: 6.9.13
|
||||
|
||||
next-auth@4.24.7(next@14.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
'@panva/hkdf': 1.1.1
|
||||
cookie: 0.5.0
|
||||
jose: 4.15.5
|
||||
next: 14.2.3(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
oauth: 0.9.15
|
||||
openid-client: 5.6.5
|
||||
preact: 10.19.7
|
||||
preact-render-to-string: 5.2.6(preact@10.19.7)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
uuid: 8.3.2
|
||||
optionalDependencies:
|
||||
nodemailer: 6.9.13
|
||||
|
||||
next-auth@4.24.7(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.65.0",
|
||||
"version": "2.67.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",
|
||||
@@ -78,6 +78,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 +89,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: ''
|
||||
@@ -1675,7 +1682,7 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/sessions:
|
||||
get:
|
||||
description: Get sessions.
|
||||
description: Get sessions
|
||||
operationId: sessions_list
|
||||
tags:
|
||||
- Sessions
|
||||
@@ -1846,7 +1853,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 +1931,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 +1990,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 +2134,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 +2211,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 +2608,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 +2791,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
|
||||
|
||||
@@ -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": []
|
||||
@@ -1261,7 +1266,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 +1383,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 +1439,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": []
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import teardown from "@/src/__tests__/teardown";
|
||||
|
||||
afterAll(async () => {
|
||||
await teardown();
|
||||
});
|
||||
@@ -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,321 @@
|
||||
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>;
|
||||
|
||||
// Mocks
|
||||
jest.mock("@prisma/client");
|
||||
jest.mock("ioredis");
|
||||
jest.mock("@langfuse/shared", () => ({
|
||||
env: {
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: "true",
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: 300,
|
||||
},
|
||||
}));
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -59,7 +59,7 @@ export const GroupedScoreBadges = ({
|
||||
|
||||
if (variant === "headings")
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 text-nowrap">
|
||||
{Object.entries(groupedScores)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnnotateDrawer } from "@/src/features/manual-scoring/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"
|
||||
|
||||
@@ -15,6 +15,7 @@ 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>[];
|
||||
@@ -98,13 +99,20 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
column.enableHiding && (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={index}
|
||||
className="capitalize"
|
||||
checked={columnVisibility[column.accessorKey]}
|
||||
onCheckedChange={() =>
|
||||
toggleColumn(column.accessorKey.toString())
|
||||
}
|
||||
onCheckedChange={() => toggleColumn(column.accessorKey)}
|
||||
>
|
||||
{column.header?.toString() ?? column.accessorKey.toString()}
|
||||
<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>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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>[];
|
||||
@@ -106,8 +106,34 @@ 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,
|
||||
columnVisibility,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -121,6 +147,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
"w-full overflow-auto",
|
||||
isBorderless ? "" : "rounded-md border",
|
||||
)}
|
||||
style={{ ...columnSizeVars }}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -134,9 +161,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 +211,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 +245,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 +284,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,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 {
|
||||
@@ -46,7 +43,8 @@ import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAl
|
||||
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 { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
|
||||
export type GenerationsTableRow = {
|
||||
id: string;
|
||||
@@ -109,15 +107,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 +142,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,
|
||||
]);
|
||||
@@ -239,6 +245,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 +263,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 +286,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "traceName",
|
||||
id: "traceName",
|
||||
header: "Trace Name",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -284,6 +294,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "startTime",
|
||||
id: "startTime",
|
||||
header: "Start Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -295,6 +306,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "endTime",
|
||||
id: "endTime",
|
||||
header: "End Time",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -302,6 +314,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "timeToFirstToken",
|
||||
id: "timeToFirstToken",
|
||||
header: "Time to First Token",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
@@ -319,6 +332,12 @@ export default function GenerationsTable({
|
||||
accessorKey: "scores",
|
||||
id: "scores",
|
||||
header: "Scores",
|
||||
size: 200,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Scores are used to evaluate the quality of the trace. They can be automated, based on user feedback, or manually annotated. See docs to learn more.",
|
||||
href: "https://langfuse.com/docs/scores",
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const values: ScoreSimplified[] | undefined = row.getValue("scores");
|
||||
return (
|
||||
@@ -331,6 +350,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "latency",
|
||||
id: "latency",
|
||||
header: "Latency",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const latency: number | undefined = row.getValue("latency");
|
||||
return latency !== undefined ? (
|
||||
@@ -344,6 +364,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 +389,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 +405,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 +421,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 +436,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 +463,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 +476,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "model",
|
||||
id: "model",
|
||||
header: "Model",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -447,6 +484,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "inputTokens",
|
||||
id: "inputTokens",
|
||||
header: "Input Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -463,6 +501,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "outputTokens",
|
||||
id: "outputTokens",
|
||||
header: "Output Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -479,6 +518,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "totalTokens",
|
||||
id: "totalTokens",
|
||||
header: "Total Tokens",
|
||||
size: 100,
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
enableSorting: true,
|
||||
@@ -495,6 +535,7 @@ export default function GenerationsTable({
|
||||
accessorKey: "usage",
|
||||
header: "Usage",
|
||||
id: "usage",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const value: {
|
||||
promptTokens: number;
|
||||
@@ -517,6 +558,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 +578,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 +597,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 +621,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 +633,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,7 +650,6 @@ export default function GenerationsTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/prompts/${encodeURIComponent(promptName)}?version=${promptVersion}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -642,7 +699,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 +709,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,13 +9,13 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { isNumericDataType } from "@/src/features/manual-scoring/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
|
||||
|
||||
@@ -11,10 +11,7 @@ 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 { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { type RouterInput, type RouterOutput } from "@/src/utils/types";
|
||||
import { type RowSelectionState } from "@tanstack/react-table";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -40,8 +37,9 @@ 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 { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
|
||||
export type TracesTableRow = {
|
||||
bookmarked: boolean;
|
||||
@@ -92,15 +90,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 +101,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 +122,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 +152,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: {
|
||||
@@ -206,6 +211,7 @@ export default function TracesTable({
|
||||
{
|
||||
id: "select",
|
||||
accessorKey: "select",
|
||||
size: 30,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -238,6 +244,7 @@ export default function TracesTable({
|
||||
accessorKey: "bookmarked",
|
||||
header: undefined,
|
||||
id: "bookmarked",
|
||||
size: 30,
|
||||
cell: ({ row }) => {
|
||||
const bookmarked: TracesTableRow["bookmarked"] =
|
||||
row.getValue("bookmarked");
|
||||
@@ -259,6 +266,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 +282,7 @@ export default function TracesTable({
|
||||
accessorKey: "timestamp",
|
||||
header: "Timestamp",
|
||||
id: "timestamp",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -281,20 +290,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 +319,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 +341,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 +354,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 +367,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 +380,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 +390,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 (
|
||||
@@ -390,6 +412,7 @@ export default function TracesTable({
|
||||
accessorKey: "inputCost",
|
||||
id: "inputCost",
|
||||
header: "Input Cost",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["inputCost"] = row.getValue("inputCost");
|
||||
return (
|
||||
@@ -410,6 +433,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 +454,7 @@ export default function TracesTable({
|
||||
accessorKey: "totalCost",
|
||||
id: "totalCost",
|
||||
header: "Total Cost",
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const cost: TracesTableRow["totalCost"] = row.getValue("totalCost");
|
||||
return (
|
||||
@@ -449,6 +474,12 @@ export default function TracesTable({
|
||||
accessorKey: "scores",
|
||||
id: "scores",
|
||||
header: "Scores",
|
||||
size: 500,
|
||||
headerTooltip: {
|
||||
description:
|
||||
"Scores are used to evaluate the quality of the trace. They can be automated, based on user feedback, or manually annotated. See docs to learn more.",
|
||||
href: "https://langfuse.com/docs/scores",
|
||||
},
|
||||
enableColumnFilter: !omittedFilter.find((f) => f === "scores"),
|
||||
cell: ({ row }) => {
|
||||
const values: TracesTableRow["scores"] = row.getValue("scores");
|
||||
@@ -460,6 +491,7 @@ export default function TracesTable({
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 400,
|
||||
cell: ({ row }) => {
|
||||
const traceId: TracesTableRow["id"] = row.getValue("id");
|
||||
return (
|
||||
@@ -477,6 +509,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 +526,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 +548,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 +570,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 +582,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 +595,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 +608,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 +625,7 @@ export default function TracesTable({
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
tracesFilter={tracesAllQueryFilter}
|
||||
className={cn(rowHeight !== "s" && "flex-wrap")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -575,6 +634,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" ? (
|
||||
@@ -605,7 +665,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 +686,8 @@ export default function TracesTable({
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { 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}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { 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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
@@ -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.67.0";
|
||||
|
||||
@@ -93,7 +93,6 @@ export const EvalConfigDetail = () => {
|
||||
`${existingEvalConfig.evalTemplate.name} (v${existingEvalConfig.evalTemplate.version})` ??
|
||||
""
|
||||
}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -337,7 +337,6 @@ export const InnerEvalConfigForm = (props: {
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
@@ -569,7 +568,7 @@ function VariableMappingDescription(p: {
|
||||
return (
|
||||
<div className="flex w-1/2 items-center">
|
||||
<Label className="muted-foreground text-sm font-light">{p.title}</Label>
|
||||
<DocPopup description={p.description} href={p.href} size="xs" />
|
||||
<DocPopup description={p.description} href={p.href} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
|
||||
columnHelper.accessor("id", {
|
||||
header: "Id",
|
||||
id: "id",
|
||||
size: 100,
|
||||
cell: (row) => {
|
||||
const id = row.getValue();
|
||||
return id ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/configs/${encodeURIComponent(id)}`}
|
||||
value={id}
|
||||
truncateAt={50}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -51,6 +51,7 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
|
||||
columnHelper.accessor("status", {
|
||||
header: "Status",
|
||||
id: "status",
|
||||
size: 80,
|
||||
cell: (row) => {
|
||||
const status = row.getValue();
|
||||
return <StatusBadge type={status.toLowerCase()} />;
|
||||
@@ -59,18 +60,22 @@ export default function EvalConfigTable({ projectId }: { projectId: string }) {
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "createdAt",
|
||||
header: "Created At",
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor("template", {
|
||||
id: "template",
|
||||
header: "Template",
|
||||
size: 200,
|
||||
}),
|
||||
columnHelper.accessor("scoreName", {
|
||||
id: "scoreName",
|
||||
header: "Score Name",
|
||||
size: 150,
|
||||
}),
|
||||
columnHelper.accessor("filter", {
|
||||
id: "filter",
|
||||
header: "Filter",
|
||||
size: 200,
|
||||
cell: (row) => {
|
||||
const node = row.getValue();
|
||||
return <InlineFilterState filterState={node} />;
|
||||
|
||||
@@ -23,7 +23,6 @@ export type JobExecutionRow = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
|
||||
export default function EvalLogTable({
|
||||
projectId,
|
||||
jobConfigurationId,
|
||||
@@ -116,7 +115,6 @@ export default function EvalLogTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/traces/${encodeURIComponent(traceId)}`}
|
||||
value={traceId}
|
||||
truncateAt={10}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -130,7 +128,6 @@ export default function EvalLogTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/templates/${encodeURIComponent(templateId)}`}
|
||||
value={templateId}
|
||||
truncateAt={10}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
@@ -148,7 +145,6 @@ export default function EvalLogTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/configs/${encodeURIComponent(configId)}`}
|
||||
value={configId}
|
||||
truncateAt={10}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function EvalsTemplateTable({
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/templates/${encodeURIComponent(id)}`}
|
||||
value={name}
|
||||
truncateAt={50}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { isEeEnabled } from "@/src/ee/utils/isEeEnabled";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { encrypt } from "@langfuse/shared/encryption";
|
||||
import { SsoProviderSchema } from "./types";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export async function createNewSsoConfigHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
try {
|
||||
if (!isEeEnabled) {
|
||||
res.status(403).json({ error: "EE is not available" });
|
||||
return;
|
||||
}
|
||||
// allow only POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ error: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
// check if ADMIN_API_KEY is set
|
||||
if (!env.ADMIN_API_KEY) {
|
||||
res.status(500).json({ error: "ADMIN_API_KEY is not set" });
|
||||
return;
|
||||
}
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
res.status(500).json({ error: "ENCRYPTION_KEY is not set" });
|
||||
return;
|
||||
}
|
||||
// check bearer token
|
||||
const { authorization } = req.headers;
|
||||
if (!authorization) {
|
||||
res
|
||||
.status(401)
|
||||
.json({ error: "Unauthorized: No authorization header provided" });
|
||||
return;
|
||||
}
|
||||
const [scheme, token] = authorization.split(" ");
|
||||
if (scheme !== "Bearer" || !token || token !== env.ADMIN_API_KEY) {
|
||||
res.status(401).json({ error: "Unauthorized: Invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = SsoProviderSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { domain, authProvider, authConfig } = body.data;
|
||||
|
||||
const encryptedClientSecret = authConfig
|
||||
? {
|
||||
...authConfig,
|
||||
clientSecret: encrypt(authConfig.clientSecret),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await prisma.ssoConfig.create({
|
||||
data: {
|
||||
domain,
|
||||
authProvider,
|
||||
authConfig: encryptedClientSecret,
|
||||
},
|
||||
});
|
||||
res.status(201).json({
|
||||
message: "SSO configuration created successfully",
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,21 @@ import OktaProvider from "next-auth/providers/okta";
|
||||
import CognitoProvider from "next-auth/providers/cognito";
|
||||
import Auth0Provider from "next-auth/providers/auth0";
|
||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||
import { isEeAvailable } from "..";
|
||||
import { isEeEnabled } from "@/src/ee/utils/isEeEnabled";
|
||||
import { type SsoConfig, prisma } from "@langfuse/shared/src/db";
|
||||
import { encrypt, decrypt } from "@langfuse/shared/encryption";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { SsoProviderSchema } from "./types";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { env } from "../env";
|
||||
import { CustomSSOProvider } from "@langfuse/shared/src/server";
|
||||
import * as Sentry from "@sentry/node";
|
||||
|
||||
// Local cache for SSO configurations
|
||||
let cachedSsoConfigs: {
|
||||
data: SsoProviderSchema[];
|
||||
failedToFetch: boolean;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
} =
|
||||
// initialize with empty cache
|
||||
{ data: [], failedToFetch: false, timestamp: 0 };
|
||||
|
||||
/**
|
||||
* Get all SSO configurations from the database or from local cache and parse them into SsoProviderSchema objects.
|
||||
@@ -25,35 +27,35 @@ let cachedSsoConfigs: {
|
||||
* @returns {Promise<SsoProviderSchema[]>} - A list of all SSO configurations. Empty array if none are configured or EE is not available.
|
||||
*/
|
||||
async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
|
||||
if (!isEeAvailable) return [];
|
||||
const CACHE_TTL = 60 * 1000; // 1 minute
|
||||
const DB_MAX_WAIT = 2000; // 2 seconds
|
||||
const DB_TIMEOUT = 3000; // 3 seconds
|
||||
if (!isEeEnabled) return [];
|
||||
|
||||
// Set/refresh the cache if it's empty or expired
|
||||
if (
|
||||
cachedSsoConfigs === null ||
|
||||
Date.now() - cachedSsoConfigs.timestamp > CACHE_TTL
|
||||
) {
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
const FAILEDTOFETCH_RETRY_AFTER = 60 * 1000; // 1 minute
|
||||
const DB_MAX_WAIT = 2 * 1000; // 2 seconds
|
||||
const DB_TIMEOUT = 3 * 1000; // 3 seconds
|
||||
|
||||
const isCacheExpired =
|
||||
Date.now() - cachedSsoConfigs.timestamp >
|
||||
(cachedSsoConfigs.failedToFetch ? FAILEDTOFETCH_RETRY_AFTER : CACHE_TTL);
|
||||
|
||||
if (isCacheExpired) {
|
||||
// findMany with custom timeout via $transaction
|
||||
let dbConfigs: SsoConfig[] = [];
|
||||
let failedToFetch = false;
|
||||
try {
|
||||
dbConfigs = await prisma.$transaction(
|
||||
async (prisma) => prisma.ssoConfig.findMany(),
|
||||
{
|
||||
maxWait: DB_MAX_WAIT,
|
||||
timeout: DB_TIMEOUT,
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// cache empty array to prevent repeated DB calls on error
|
||||
cachedSsoConfigs = {
|
||||
data: [],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
console.error("Failed to load SSO configs from the database", e);
|
||||
Sentry.captureException(e);
|
||||
|
||||
// caught and logged in the caller
|
||||
throw e;
|
||||
// empty array will be cached to prevent repeated DB queries
|
||||
failedToFetch = true;
|
||||
}
|
||||
|
||||
// transform into zod object
|
||||
@@ -65,16 +67,18 @@ async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to parse SSO provider config for domain ${v.domain}`,
|
||||
e
|
||||
e,
|
||||
);
|
||||
Sentry.captureException(e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((parsed) => parsed !== null) as SsoProviderSchema[];
|
||||
.filter((parsed): parsed is SsoProviderSchema => parsed !== null);
|
||||
|
||||
cachedSsoConfigs = {
|
||||
data: parsedSsoConfigs,
|
||||
timestamp: Date.now(),
|
||||
failedToFetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ async function getSsoConfigs(): Promise<SsoProviderSchema[]> {
|
||||
* @returns {Promise<Provider[]>} - A list of all custom SSO providers.
|
||||
*/
|
||||
export async function loadSsoProviders(): Promise<Provider[]> {
|
||||
if (!isEeAvailable) return [];
|
||||
if (!isEeEnabled) return [];
|
||||
|
||||
const ssoConfigs = await getSsoConfigs();
|
||||
|
||||
@@ -105,7 +109,7 @@ export async function loadSsoProviders(): Promise<Provider[]> {
|
||||
* @returns `true` if any custom SSO provider is configured in the database.
|
||||
*/
|
||||
export async function isAnySsoConfigured(): Promise<boolean> {
|
||||
if (!isEeAvailable) return false;
|
||||
if (!isEeEnabled) return false;
|
||||
const ssoConfigs = await getSsoConfigs();
|
||||
return ssoConfigs.length > 0;
|
||||
}
|
||||
@@ -117,11 +121,11 @@ export async function isAnySsoConfigured(): Promise<boolean> {
|
||||
* @returns `providerId` or null if none is configured or EE is not available.
|
||||
*/
|
||||
export async function getSsoAuthProviderIdForDomain(
|
||||
domain: string
|
||||
domain: string,
|
||||
): Promise<string | null> {
|
||||
if (!isEeAvailable) return null;
|
||||
if (!isEeEnabled) return null;
|
||||
const ssoConfig = (await getSsoConfigs()).find(
|
||||
(ssoConfig) => ssoConfig.domain === domain.toLowerCase()
|
||||
(ssoConfig) => ssoConfig.domain === domain.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!ssoConfig) return null;
|
||||
@@ -185,11 +189,17 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
|
||||
});
|
||||
else {
|
||||
// Type check to ensure we handle all providers
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _: never = provider;
|
||||
throw new Error(
|
||||
`Unrecognized SSO provider for domain ${(provider as any).domain}`
|
||||
console.error(
|
||||
`Unrecognized SSO provider for domain ${(provider as any).domain}`,
|
||||
);
|
||||
Sentry.captureException(
|
||||
new Error(
|
||||
`Unrecognized SSO provider for domain ${(provider as any).domain}`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,76 +210,8 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
|
||||
* @returns {string} - The providerId used in NextAuth.
|
||||
*/
|
||||
const getAuthProviderIdForSsoConfig = (
|
||||
dbSsoConfig: SsoProviderSchema
|
||||
dbSsoConfig: SsoProviderSchema,
|
||||
): string => {
|
||||
if (!dbSsoConfig.authConfig) return dbSsoConfig.authProvider;
|
||||
return `${dbSsoConfig.domain}.${dbSsoConfig.authProvider}`;
|
||||
};
|
||||
|
||||
export async function createNewSsoConfigHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
if (!isEeAvailable) {
|
||||
res.status(403).json({ error: "EE is not available" });
|
||||
return;
|
||||
}
|
||||
// allow only POST requests
|
||||
if (req.method !== "POST") {
|
||||
res.status(405).json({ error: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
// check if ADMIN_API_KEY is set
|
||||
if (!env.ADMIN_API_KEY) {
|
||||
res.status(500).json({ error: "ADMIN_API_KEY is not set" });
|
||||
return;
|
||||
}
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
res.status(500).json({ error: "ENCRYPTION_KEY is not set" });
|
||||
return;
|
||||
}
|
||||
// check bearer token
|
||||
const { authorization } = req.headers;
|
||||
if (!authorization) {
|
||||
res
|
||||
.status(401)
|
||||
.json({ error: "Unauthorized: No authorization header provided" });
|
||||
return;
|
||||
}
|
||||
const [scheme, token] = authorization.split(" ");
|
||||
if (scheme !== "Bearer" || !token || token !== env.ADMIN_API_KEY) {
|
||||
res.status(401).json({ error: "Unauthorized: Invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = SsoProviderSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { domain, authProvider, authConfig } = body.data;
|
||||
|
||||
const encryptedClientSecret = authConfig
|
||||
? {
|
||||
...authConfig,
|
||||
clientSecret: encrypt(authConfig.clientSecret),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await prisma.ssoConfig.create({
|
||||
data: {
|
||||
domain,
|
||||
authProvider,
|
||||
authConfig: encryptedClientSecret,
|
||||
},
|
||||
});
|
||||
res.status(201).json({
|
||||
message: "SSO configuration created successfully",
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
+35
-12
@@ -29,16 +29,6 @@ export const env = createEnv({
|
||||
LANGFUSE_NEW_USER_SIGNUP_WEBHOOK: z.string().url().optional(),
|
||||
// Add `.min(1) on ID and SECRET if you want to make sure they're not empty
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: z.enum(["true", "false"]).optional(),
|
||||
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => v === undefined || !isNaN(Number.parseInt(v)), {
|
||||
message:
|
||||
"LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET must be a number when set",
|
||||
})
|
||||
.transform(
|
||||
(v) => (v === undefined ? undefined : -Number.parseInt(v)), // negative offset
|
||||
),
|
||||
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
@@ -87,6 +77,7 @@ export const env = createEnv({
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
|
||||
AUTH_SESSION_MAX_AGE: z.coerce.number().int().gt(5, "AUTH_SESSION_MAX_AGE must be > 5 as session JWT tokens are refreshed every 5 minutes").optional().default(30 * 24 * 60), // default to 30 days
|
||||
// EMAIL
|
||||
EMAIL_FROM_ADDRESS: z
|
||||
.string()
|
||||
@@ -112,6 +103,29 @@ export const env = createEnv({
|
||||
ENABLE_EVENT_LOG: z.enum(["true", "false"]).optional().default("true"),
|
||||
// EE License
|
||||
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
|
||||
ADMIN_API_KEY: z.string().optional(),
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.length(
|
||||
64,
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
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(),
|
||||
// langfuse caching
|
||||
LANGFUSE_CACHE_API_KEY_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_CACHE_API_KEY_TTL_SECONDS: z.coerce.number().default(120),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -151,8 +165,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_SIGN_UP_DISABLED: process.env.NEXT_PUBLIC_SIGN_UP_DISABLED,
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:
|
||||
process.env.LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES,
|
||||
LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET:
|
||||
process.env.LANGFUSE_DEFAULT_TABLE_DATETIME_OFFSET,
|
||||
LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES:
|
||||
process.env.LANGFUSE_DISABLE_EXPENSIVE_POSTGRES_QUERIES,
|
||||
LANGFUSE_TEAM_SLACK_WEBHOOK: process.env.LANGFUSE_TEAM_SLACK_WEBHOOK,
|
||||
@@ -205,6 +217,7 @@ export const env = createEnv({
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
AUTH_DISABLE_SIGNUP: process.env.AUTH_DISABLE_SIGNUP,
|
||||
AUTH_SESSION_MAX_AGE: process.env.AUTH_SESSION_MAX_AGE,
|
||||
// Email
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
SMTP_CONNECTION_URL: process.env.SMTP_CONNECTION_URL,
|
||||
@@ -229,6 +242,16 @@ export const env = createEnv({
|
||||
ENABLE_EVENT_LOG: process.env.ENABLE_EVENT_LOG,
|
||||
// EE License
|
||||
LANGFUSE_EE_LICENSE_KEY: process.env.LANGFUSE_EE_LICENSE_KEY,
|
||||
ADMIN_API_KEY: process.env.ADMIN_API_KEY,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
REDIS_HOST: process.env.REDIS_HOST,
|
||||
REDIS_PORT: process.env.REDIS_PORT,
|
||||
REDIS_AUTH: process.env.REDIS_AUTH,
|
||||
REDIS_CONNECTION_STRING: process.env.REDIS_CONNECTION_STRING,
|
||||
// langfuse caching
|
||||
LANGFUSE_CACHE_API_KEY_ENABLED: process.env.LANGFUSE_CACHE_API_KEY_ENABLED,
|
||||
LANGFUSE_CACHE_API_KEY_TTL_SECONDS:
|
||||
process.env.LANGFUSE_CACHE_API_KEY_TTL_SECONDS,
|
||||
},
|
||||
// Skip validation in Docker builds
|
||||
// DOCKER_BUILD is set in Dockerfile
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Public API
|
||||
|
||||
## How to add new api routes
|
||||
|
||||
Implementation
|
||||
|
||||
- Wrap with `withMiddleware`
|
||||
- Type-safe and authed API Route with `createAuthedAPIRoute`
|
||||
- Add zod types to `/features/public-api/types` folder.
|
||||
|
||||
- Use [`coerce`](https://zod.dev/?id=coercion-for-primitives) to handle primitives, such as dates, for use in your application and tests.
|
||||
- Use `strict()` on all objects that should not return additional properties. Recommended as default. In these cases, the test utility `makeZodVerifiedAPICall` will throw an error if the response contains additional properties. Also, we will log an error in production if the response contains additional properties.
|
||||
|
||||
- Throw errors defined in `shared/src/errors` which translate to HTTP status codes
|
||||
|
||||
Testing
|
||||
|
||||
- Add tests for all standard cases
|
||||
- use `makeZodVerifiedAPICall` to test the API response against the zod response schema
|
||||
|
||||
API Reference
|
||||
|
||||
- Add to `fern` including `docs` attributes
|
||||
- Build with `fern generate --api server` and `fern generate --api client`, then commit the changes to the API reference
|
||||
|
||||
SDKs
|
||||
|
||||
- Copy/paste fern-generated types or api reference to Python and JS SDKs respectively
|
||||
- Implement wrapping functions if needed
|
||||
|
||||
How to refactor existing apis to this pattern
|
||||
|
||||
1. Move request types to `features/public-api/types`
|
||||
2. Create response types in `features/public-api/types`
|
||||
3. Validate response type with `validateZodSchema` in the API route to get type warnings in case of mismatch
|
||||
4. Refactor api route by passing current api route to LLM together with example of e.g. public/v2/datasets.ts
|
||||
@@ -1,7 +1,7 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { createUserEmailPassword } from "@/src/features/auth-credentials/lib/credentialsServerUtils";
|
||||
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
|
||||
import { getSsoAuthProviderIdForDomain } from "@langfuse/ee/sso";
|
||||
import { getSsoAuthProviderIdForDomain } from "@/src/ee/features/multi-tenant-sso/utils";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type DateTimeAggregationOption } from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { AreaChart, type CustomTooltipProps, LineChart } from "@tremor/react";
|
||||
import { Tooltip } from "@/src/features/dashboard/components/Tooltip";
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
|
||||
export type TimeSeriesChartDataPoint = {
|
||||
ts: number;
|
||||
@@ -12,7 +12,7 @@ export type TimeSeriesChartDataPoint = {
|
||||
|
||||
export function BaseTimeSeriesChart(props: {
|
||||
className?: string;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
data: TimeSeriesChartDataPoint[];
|
||||
showLegend?: boolean;
|
||||
connectNulls?: boolean;
|
||||
@@ -41,10 +41,13 @@ export function BaseTimeSeriesChart(props: {
|
||||
});
|
||||
}
|
||||
|
||||
const convertDate = (date: number, agg: DateTimeAggregationOption) => {
|
||||
const showMinutes: DateTimeAggregationOption[] = [
|
||||
"5 minutes",
|
||||
"30 minutes",
|
||||
const convertDate = (
|
||||
date: number,
|
||||
agg: DashboardDateRangeAggregationOption,
|
||||
) => {
|
||||
const showMinutes: DashboardDateRangeAggregationOption[] = [
|
||||
"5 min",
|
||||
"30 min",
|
||||
"1 hour",
|
||||
"3 hours",
|
||||
];
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
dateTimeAggregationSettings,
|
||||
type DateTimeAggregationOption,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
|
||||
import { BaseTimeSeriesChart } from "@/src/features/dashboard/components/BaseTimeSeriesChart";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
@@ -14,10 +11,14 @@ import {
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { createTracesTimeFilter } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
import {
|
||||
dashboardDateRangeAggregationSettings,
|
||||
type DashboardDateRangeAggregationOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
|
||||
export function ChartScores(props: {
|
||||
className?: string;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
globalFilterState: FilterState;
|
||||
projectId: string;
|
||||
}) {
|
||||
@@ -31,7 +32,8 @@ export function ChartScores(props: {
|
||||
{
|
||||
type: "datetime",
|
||||
column: "timestamp",
|
||||
temporalUnit: dateTimeAggregationSettings[props.agg].date_trunc,
|
||||
temporalUnit:
|
||||
dashboardDateRangeAggregationSettings[props.agg].date_trunc,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
dateTimeAggregationSettings,
|
||||
type DateTimeAggregationOption,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import {
|
||||
getAllModels,
|
||||
@@ -15,6 +11,10 @@ import { BaseTimeSeriesChart } from "@/src/features/dashboard/components/BaseTim
|
||||
import { TabComponent } from "@/src/features/dashboard/components/TabsComponent";
|
||||
import { latencyFormatter } from "@/src/utils/numbers";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import {
|
||||
dashboardDateRangeAggregationSettings,
|
||||
type DashboardDateRangeAggregationOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
|
||||
export const GenerationLatencyChart = ({
|
||||
className,
|
||||
@@ -25,7 +25,7 @@ export const GenerationLatencyChart = ({
|
||||
className?: string;
|
||||
projectId: string;
|
||||
globalFilterState: FilterState;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
}) => {
|
||||
const latencies = api.dashboard.chart.useQuery(
|
||||
{
|
||||
@@ -52,7 +52,7 @@ export const GenerationLatencyChart = ({
|
||||
{
|
||||
type: "datetime",
|
||||
column: "startTime",
|
||||
temporalUnit: dateTimeAggregationSettings[agg].date_trunc,
|
||||
temporalUnit: dashboardDateRangeAggregationSettings[agg].date_trunc,
|
||||
},
|
||||
{ type: "string", column: "model" },
|
||||
],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
import {
|
||||
dateTimeAggregationSettings,
|
||||
type DateTimeAggregationOption,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
|
||||
import {
|
||||
@@ -19,6 +15,10 @@ import { BaseTimeSeriesChart } from "@/src/features/dashboard/components/BaseTim
|
||||
import { TotalMetric } from "@/src/features/dashboard/components/TotalMetric";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import { totalCostDashboardFormatted } from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
import {
|
||||
dashboardDateRangeAggregationSettings,
|
||||
type DashboardDateRangeAggregationOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ModelUsageChart = ({
|
||||
className?: string;
|
||||
projectId: string;
|
||||
globalFilterState: FilterState;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
}) => {
|
||||
const tokens = api.dashboard.chart.useQuery(
|
||||
{
|
||||
@@ -52,7 +52,7 @@ export const ModelUsageChart = ({
|
||||
{
|
||||
type: "datetime",
|
||||
column: "startTime",
|
||||
temporalUnit: dateTimeAggregationSettings[agg].date_trunc,
|
||||
temporalUnit: dashboardDateRangeAggregationSettings[agg].date_trunc,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
|
||||
@@ -3,13 +3,12 @@ import {
|
||||
type TimeSeriesChartDataPoint,
|
||||
} from "@/src/features/dashboard/components/BaseTimeSeriesChart";
|
||||
import { TotalMetric } from "@/src/features/dashboard/components/TotalMetric";
|
||||
import { type DateTimeAggregationOption } from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
import { Tab, TabList, TabGroup, TabPanel, TabPanels } from "@tremor/react";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
export type BaseTabTimeseriesChartProps = {
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
showLegend?: boolean;
|
||||
connectNulls?: boolean;
|
||||
data: {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
dateTimeAggregationSettings,
|
||||
type DateTimeAggregationOption,
|
||||
} from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import { BaseTimeSeriesChart } from "@/src/features/dashboard/components/BaseTimeSeriesChart";
|
||||
@@ -11,6 +7,10 @@ import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { isEmptyTimeSeries } from "@/src/features/dashboard/components/hooks";
|
||||
import { NoData } from "@/src/features/dashboard/components/NoData";
|
||||
import {
|
||||
dashboardDateRangeAggregationSettings,
|
||||
type DashboardDateRangeAggregationOption,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
|
||||
export const TracesTimeSeriesChart = ({
|
||||
className,
|
||||
@@ -21,7 +21,7 @@ export const TracesTimeSeriesChart = ({
|
||||
className?: string;
|
||||
projectId: string;
|
||||
globalFilterState: FilterState;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
}) => {
|
||||
const traces = api.dashboard.chart.useQuery(
|
||||
{
|
||||
@@ -35,7 +35,7 @@ export const TracesTimeSeriesChart = ({
|
||||
{
|
||||
type: "datetime",
|
||||
column: "timestamp",
|
||||
temporalUnit: dateTimeAggregationSettings[agg].date_trunc,
|
||||
temporalUnit: dashboardDateRangeAggregationSettings[agg].date_trunc,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type DateTimeAggregationOption } from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { DashboardCard } from "@/src/features/dashboard/components/cards/DashboardCard";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
@@ -14,8 +13,8 @@ import {
|
||||
createTracesTimeFilter,
|
||||
totalCostDashboardFormatted,
|
||||
} from "@/src/features/dashboard/lib/dashboard-utils";
|
||||
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { type DashboardDateRangeAggregationOption } from "@/src/utils/date-range-utils";
|
||||
|
||||
type BarChartDataPoint = {
|
||||
name: string;
|
||||
@@ -30,7 +29,7 @@ export const UserChart = ({
|
||||
className?: string;
|
||||
projectId: string;
|
||||
globalFilterState: FilterState;
|
||||
agg: DateTimeAggregationOption;
|
||||
agg: DashboardDateRangeAggregationOption;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const user = api.dashboard.chart.useQuery(
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { type DateRange } from "react-day-picker";
|
||||
|
||||
export const dateTimeAggregationOptions = [
|
||||
"1 year",
|
||||
"3 months",
|
||||
"1 month",
|
||||
"7 days",
|
||||
"24 hours",
|
||||
"3 hours",
|
||||
"1 hour",
|
||||
"30 minutes",
|
||||
"5 minutes",
|
||||
] as const;
|
||||
|
||||
export type DateTimeAggregationOption =
|
||||
(typeof dateTimeAggregationOptions)[number];
|
||||
|
||||
export const dateTimeAggregationSettings: Record<
|
||||
DateTimeAggregationOption,
|
||||
{
|
||||
date_trunc: "year" | "month" | "week" | "day" | "hour" | "minute";
|
||||
date_formatter: (date: Date) => string;
|
||||
minutes: number;
|
||||
}
|
||||
> = {
|
||||
"1 year": {
|
||||
date_trunc: "month",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleDateString("en-US", { year: "2-digit", month: "short" }),
|
||||
minutes: 365 * 24 * 60,
|
||||
},
|
||||
"3 months": {
|
||||
date_trunc: "day",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
minutes: 3 * 30 * 24 * 60,
|
||||
},
|
||||
"1 month": {
|
||||
date_trunc: "day",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
minutes: 30 * 24 * 60,
|
||||
},
|
||||
"7 days": {
|
||||
date_trunc: "day",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
||||
minutes: 7 * 24 * 60,
|
||||
},
|
||||
"24 hours": {
|
||||
date_trunc: "hour",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleTimeString("en-US", { hour: "numeric" }),
|
||||
minutes: 24 * 60,
|
||||
},
|
||||
"3 hours": {
|
||||
date_trunc: "minute",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric" }),
|
||||
minutes: 3 * 60,
|
||||
},
|
||||
"1 hour": {
|
||||
date_trunc: "minute",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric" }),
|
||||
minutes: 60,
|
||||
},
|
||||
"30 minutes": {
|
||||
date_trunc: "minute",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric" }),
|
||||
minutes: 30,
|
||||
},
|
||||
"5 minutes": {
|
||||
date_trunc: "minute",
|
||||
date_formatter: (date) =>
|
||||
date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric" }),
|
||||
minutes: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const findClosestInterval = (
|
||||
dateRange: DateRange,
|
||||
): DateTimeAggregationOption | undefined => {
|
||||
// Check for valid date range
|
||||
if (!dateRange.from || !dateRange.to) return undefined;
|
||||
|
||||
const duration = dateRange.to.getTime() - dateRange.from.getTime();
|
||||
|
||||
// Map intervals to their difference from the given duration
|
||||
const diffs = dateTimeAggregationOptions.map((interval) => {
|
||||
const { minutes } = dateTimeAggregationSettings[interval];
|
||||
return {
|
||||
interval: interval,
|
||||
diff: Math.abs(duration - minutes * 60 * 1000),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by difference and pick the first one
|
||||
diffs.sort((a, b) => a.diff - b.diff);
|
||||
|
||||
return diffs[0]?.interval;
|
||||
};
|
||||
@@ -85,13 +85,13 @@ export function DatasetItemsTable({
|
||||
accessorKey: "id",
|
||||
header: "Item id",
|
||||
id: "id",
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const id: string = row.getValue("id");
|
||||
return (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/datasets/${datasetId}/items/${id}`}
|
||||
value={id}
|
||||
truncateAt={7}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -104,6 +104,7 @@ export function DatasetItemsTable({
|
||||
"Link to the source trace based on which this item was added",
|
||||
},
|
||||
id: "source",
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const source: RowData["source"] = row.getValue("source");
|
||||
if (!source) return null;
|
||||
@@ -126,6 +127,7 @@ export function DatasetItemsTable({
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
id: "status",
|
||||
size: 80,
|
||||
cell: ({ row }) => {
|
||||
const status: DatasetStatus = row.getValue("status");
|
||||
return (
|
||||
@@ -147,12 +149,14 @@ export function DatasetItemsTable({
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
id: "createdAt",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const input = row.getValue("input") as RowData["input"];
|
||||
@@ -165,6 +169,7 @@ export function DatasetItemsTable({
|
||||
accessorKey: "expectedOutput",
|
||||
header: "Expected Output",
|
||||
id: "expectedOutput",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const expectedOutput = row.getValue(
|
||||
@@ -183,6 +188,7 @@ export function DatasetItemsTable({
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
id: "metadata",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const metadata = row.getValue("metadata") as RowData["metadata"];
|
||||
@@ -195,6 +201,7 @@ export function DatasetItemsTable({
|
||||
id: "actions",
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
const id: string = row.getValue("id");
|
||||
const status: DatasetStatus = row.getValue("status");
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffect } from "react";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { ListTree } from "lucide-react";
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
@@ -81,18 +82,19 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "runAt",
|
||||
header: "Run At",
|
||||
id: "runAt",
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "datasetItemId",
|
||||
header: "Dataset Item",
|
||||
id: "datasetItemId",
|
||||
size: 110,
|
||||
cell: ({ row }) => {
|
||||
const datasetItemId: string = row.getValue("datasetItemId");
|
||||
return (
|
||||
<TableLink
|
||||
path={`/project/${props.projectId}/datasets/${props.datasetId}/items/${datasetItemId}`}
|
||||
value={datasetItemId}
|
||||
truncateAt={7}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -101,20 +103,21 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "trace",
|
||||
header: "Trace",
|
||||
id: "trace",
|
||||
size: 60,
|
||||
cell: ({ row }) => {
|
||||
const trace: RowData["trace"] = row.getValue("trace");
|
||||
if (!trace) return null;
|
||||
return trace.observationId ? (
|
||||
<TableLink
|
||||
path={`/project/${props.projectId}/traces/${trace.traceId}?observation=${trace.observationId}`}
|
||||
value={trace.observationId}
|
||||
truncateAt={7}
|
||||
value={`Trace: ${trace.traceId}, Observation: ${trace.observationId}`}
|
||||
icon={<ListTree className="h-4 w-4" />}
|
||||
/>
|
||||
) : (
|
||||
<TableLink
|
||||
path={`/project/${props.projectId}/traces/${trace.traceId}`}
|
||||
value={trace.traceId}
|
||||
truncateAt={7}
|
||||
value={`Trace: ${trace.traceId}`}
|
||||
icon={<ListTree className="h-4 w-4" />}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -123,6 +126,7 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "latency",
|
||||
header: "Latency",
|
||||
id: "latency",
|
||||
size: 70,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const latency: RowData["latency"] = row.getValue("latency");
|
||||
@@ -131,8 +135,9 @@ export function DatasetRunItemsTable(
|
||||
},
|
||||
{
|
||||
accessorKey: "totalCost",
|
||||
header: "Total Cost",
|
||||
header: "Cost",
|
||||
id: "totalCost",
|
||||
size: 60,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const totalCost: RowData["totalCost"] = row.getValue("totalCost");
|
||||
@@ -143,6 +148,7 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "scores",
|
||||
header: "Scores",
|
||||
id: "scores",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const scores: RowData["scores"] = row.getValue("scores");
|
||||
@@ -153,6 +159,7 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "input",
|
||||
header: "Input",
|
||||
id: "input",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const trace: RowData["trace"] = row.getValue("trace");
|
||||
@@ -170,6 +177,7 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "output",
|
||||
header: "Output",
|
||||
id: "output",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const trace: RowData["trace"] = row.getValue("trace");
|
||||
@@ -187,6 +195,7 @@ export function DatasetRunItemsTable(
|
||||
accessorKey: "expectedOutput",
|
||||
header: "Expected Output",
|
||||
id: "expectedOutput",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const datasetItemId: string = row.getValue("datasetItemId");
|
||||
@@ -208,7 +217,7 @@ export function DatasetRunItemsTable(
|
||||
): RowData => {
|
||||
return {
|
||||
id: item.id,
|
||||
runAt: item.createdAt.toISOString(),
|
||||
runAt: item.createdAt.toLocaleString(),
|
||||
datasetItemId: item.datasetItemId,
|
||||
trace: !!item.trace?.id
|
||||
? {
|
||||
|
||||
@@ -64,13 +64,13 @@ export function DatasetRunsTable(props: {
|
||||
accessorKey: "key",
|
||||
header: "Name",
|
||||
id: "key",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const key: RowData["key"] = row.getValue("key");
|
||||
return (
|
||||
<TableLink
|
||||
path={`/project/${props.projectId}/datasets/${props.datasetId}/runs/${key.id}`}
|
||||
value={key.name}
|
||||
truncateAt={50}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -79,18 +79,21 @@ export function DatasetRunsTable(props: {
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
id: "description",
|
||||
size: 300,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "countRunItems",
|
||||
header: "Run Items",
|
||||
id: "countRunItems",
|
||||
size: 90,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "avgLatency",
|
||||
header: "Latency (avg)",
|
||||
id: "avgLatency",
|
||||
size: 120,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const avgLatency: RowData["avgLatency"] = row.getValue("avgLatency");
|
||||
@@ -101,6 +104,7 @@ export function DatasetRunsTable(props: {
|
||||
accessorKey: "avgTotalCost",
|
||||
header: "Total Cost (avg)",
|
||||
id: "avgTotalCost",
|
||||
size: 130,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const avgTotalCost: RowData["avgTotalCost"] =
|
||||
@@ -112,6 +116,7 @@ export function DatasetRunsTable(props: {
|
||||
accessorKey: "scores",
|
||||
header: "Scores (avg)",
|
||||
id: "scores",
|
||||
size: 400,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const scores: RowData["scores"] = row.getValue("scores");
|
||||
@@ -131,12 +136,14 @@ export function DatasetRunsTable(props: {
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
id: "createdAt",
|
||||
size: 150,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
id: "metadata",
|
||||
size: 200,
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const metadata: RowData["metadata"] = row.getValue("metadata");
|
||||
|
||||
@@ -26,7 +26,7 @@ type RowData = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
description: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
lastRunAt?: string;
|
||||
countItems: number;
|
||||
@@ -65,13 +65,13 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
accessorKey: "key",
|
||||
header: "Name",
|
||||
id: "key",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const key: RowData["key"] = row.getValue("key");
|
||||
return (
|
||||
<TableLink
|
||||
path={`/project/${props.projectId}/datasets/${key.id}`}
|
||||
value={key.name}
|
||||
truncateAt={50}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -81,36 +81,42 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
header: "Description",
|
||||
id: "description",
|
||||
enableHiding: true,
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "countItems",
|
||||
header: "Items",
|
||||
id: "countItems",
|
||||
enableHiding: true,
|
||||
size: 60,
|
||||
},
|
||||
{
|
||||
accessorKey: "countRuns",
|
||||
header: "Runs",
|
||||
id: "countRuns",
|
||||
enableHiding: true,
|
||||
size: 60,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
id: "createdAt",
|
||||
enableHiding: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "lastRunAt",
|
||||
header: "Last Run",
|
||||
id: "lastRunAt",
|
||||
enableHiding: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
id: "metadata",
|
||||
enableHiding: true,
|
||||
size: 300,
|
||||
cell: ({ row }) => {
|
||||
const metadata: RowData["metadata"] = row.getValue("metadata");
|
||||
return !!metadata ? (
|
||||
@@ -122,6 +128,7 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
id: "actions",
|
||||
accessorKey: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: ({ row }) => {
|
||||
const key: RowData["key"] = row.getValue("key");
|
||||
return (
|
||||
|
||||
@@ -38,8 +38,16 @@ export const datasetRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const query = DB.selectFrom("datasets")
|
||||
.leftJoin("dataset_items", "datasets.id", "dataset_items.dataset_id")
|
||||
.leftJoin("dataset_runs", "datasets.id", "dataset_runs.dataset_id")
|
||||
.leftJoin("dataset_items", (join) =>
|
||||
join
|
||||
.onRef("datasets.id", "=", "dataset_items.dataset_id")
|
||||
.on("dataset_items.project_id", "=", input.projectId),
|
||||
)
|
||||
.leftJoin("dataset_runs", (join) =>
|
||||
join
|
||||
.onRef("datasets.id", "=", "dataset_runs.dataset_id")
|
||||
.on("dataset_runs.project_id", "=", input.projectId),
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"datasets.id",
|
||||
"datasets.name",
|
||||
@@ -55,6 +63,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
.groupBy([
|
||||
"datasets.id",
|
||||
"datasets.name",
|
||||
"datasets.description",
|
||||
"datasets.metadata",
|
||||
"datasets.created_at",
|
||||
"datasets.updated_at",
|
||||
])
|
||||
@@ -95,8 +105,10 @@ export const datasetRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.prisma.dataset.findUnique({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -111,11 +123,11 @@ export const datasetRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.prisma.datasetRuns.findUnique({
|
||||
where: {
|
||||
id: input.runId,
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
id_projectId: {
|
||||
id: input.runId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
datasetId: input.datasetId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -151,7 +163,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
COALESCE(run_items_count.count, 0)::int "countRunItems"
|
||||
FROM
|
||||
dataset_runs runs
|
||||
JOIN datasets ON datasets.id = runs.dataset_id
|
||||
JOIN datasets ON datasets.id = runs.dataset_id AND datasets.project_id = ${input.projectId}
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
jsonb_object_agg(s.name, s.avg_value) AS scores
|
||||
@@ -165,9 +177,9 @@ export const datasetRouter = createTRPCRouter({
|
||||
ON s.trace_id = ri.trace_id
|
||||
AND (ri.observation_id IS NULL OR s.observation_id = ri.observation_id)
|
||||
AND s.project_id = ${input.projectId}
|
||||
JOIN traces t ON t.id = s.trace_id
|
||||
JOIN traces t ON t.id = s.trace_id AND t.project_id = ${input.projectId}
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
ri.project_id = ${input.projectId}
|
||||
AND s.data_type != 'CATEGORICAL'
|
||||
AND s.value IS NOT NULL
|
||||
AND ri.dataset_run_id = runs.id
|
||||
@@ -180,19 +192,20 @@ export const datasetRouter = createTRPCRouter({
|
||||
AVG(COALESCE(o.calculated_total_cost, 0)) AS "avgTotalCost"
|
||||
FROM
|
||||
dataset_run_items ri
|
||||
JOIN observations_view o ON o.id = ri.observation_id
|
||||
JOIN observations_view o ON o.id = ri.observation_id AND o.project_id = ${input.projectId}
|
||||
WHERE
|
||||
o.project_id = ${input.projectId}
|
||||
ri.project_id = ${input.projectId}
|
||||
AND ri.dataset_run_id = runs.id
|
||||
) latency_and_total_cost ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) as count
|
||||
FROM dataset_run_items ri
|
||||
WHERE ri.dataset_run_id = runs.id
|
||||
AND ri.project_id = ${input.projectId}
|
||||
) run_items_count ON true
|
||||
WHERE
|
||||
runs.dataset_id = ${input.datasetId}
|
||||
AND datasets.project_id = ${input.projectId}
|
||||
AND runs.project_id = ${input.projectId}
|
||||
ORDER BY
|
||||
runs.created_at DESC
|
||||
LIMIT ${input.limit}
|
||||
@@ -202,9 +215,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
const totalRuns = await ctx.prisma.datasetRuns.count({
|
||||
where: {
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,11 +235,8 @@ export const datasetRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.prisma.datasetItem.findUnique({
|
||||
where: {
|
||||
id: input.datasetItemId,
|
||||
id_projectId: { id: input.datasetItemId, projectId: input.projectId },
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -241,31 +249,37 @@ export const datasetRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const datasetItems = await ctx.prisma.datasetItem.findMany({
|
||||
const dataset = await ctx.prisma.dataset.findUnique({
|
||||
where: {
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
status: "asc",
|
||||
include: {
|
||||
datasetItems: {
|
||||
orderBy: [
|
||||
{
|
||||
status: "asc",
|
||||
},
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: input.limit,
|
||||
skip: input.page * input.limit,
|
||||
},
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: input.limit,
|
||||
skip: input.page * input.limit,
|
||||
},
|
||||
});
|
||||
const datasetItems = dataset?.datasetItems ?? [];
|
||||
|
||||
const totalDatasetItems = await ctx.prisma.datasetItem.count({
|
||||
where: {
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -296,11 +310,11 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
const datasetItem = await ctx.prisma.datasetItem.update({
|
||||
where: {
|
||||
id: input.datasetItemId,
|
||||
datasetId: input.datasetId,
|
||||
dataset: {
|
||||
id_projectId: {
|
||||
id: input.datasetItemId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
datasetId: input.datasetId,
|
||||
},
|
||||
data: {
|
||||
input:
|
||||
@@ -394,8 +408,10 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
const dataset = await ctx.prisma.dataset.update({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
name: input.name ?? undefined,
|
||||
@@ -429,8 +445,10 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
const deletedDataset = await ctx.prisma.dataset.delete({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
@@ -463,8 +481,10 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
const dataset = await ctx.prisma.dataset.findUnique({
|
||||
where: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
id_projectId: {
|
||||
id: input.datasetId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!dataset) {
|
||||
@@ -494,6 +514,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
datasetId: input.datasetId,
|
||||
sourceTraceId: input.sourceTraceId,
|
||||
sourceObservationId: input.sourceObservationId,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
@@ -511,7 +532,6 @@ export const datasetRouter = createTRPCRouter({
|
||||
z
|
||||
.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
datasetRunId: z.string().optional(),
|
||||
datasetItemId: z.string().optional(),
|
||||
...paginationZod,
|
||||
@@ -524,13 +544,9 @@ export const datasetRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
const runItems = await ctx.prisma.datasetRunItems.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
datasetRunId: input.datasetRunId,
|
||||
datasetItemId: input.datasetItemId,
|
||||
datasetRun: {
|
||||
dataset: {
|
||||
projectId: ctx.session.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
@@ -564,13 +580,9 @@ export const datasetRouter = createTRPCRouter({
|
||||
|
||||
const totalRunItems = await ctx.prisma.datasetRunItems.count({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
datasetRunId: input.datasetRunId,
|
||||
datasetItemId: input.datasetItemId,
|
||||
datasetRun: {
|
||||
dataset: {
|
||||
projectId: ctx.session.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,7 +623,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
FROM
|
||||
observations o1
|
||||
WHERE
|
||||
o1.project_id = t.project_id
|
||||
o1.project_id = ${input.projectId}
|
||||
AND o1.trace_id = t.id
|
||||
GROUP BY
|
||||
o1.project_id,
|
||||
@@ -661,11 +673,9 @@ export const datasetRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.prisma.datasetItem.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
sourceTraceId: input.traceId,
|
||||
sourceObservationId: input.observationId ?? null, // null as it should not include observations from the same trace
|
||||
dataset: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
dataset: {
|
||||
|
||||
@@ -112,6 +112,9 @@ const tableCols = {
|
||||
dashboard: [
|
||||
{ id: "traceName", name: "Trace Name" },
|
||||
{ id: "tags", name: "Tags" },
|
||||
{ id: "release", name: "Release" },
|
||||
{ id: "user", name: "User" },
|
||||
{ id: "version", name: "Version" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
LockIcon,
|
||||
@@ -70,7 +70,6 @@ import Header from "@/src/components/layouts/header";
|
||||
import { MultiSelectKeyValues } from "@/src/features/manual-scoring/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { isPresent } from "@/src/utils/typeChecks";
|
||||
@@ -130,6 +129,8 @@ const getFormError = ({
|
||||
export function AnnotateDrawer({
|
||||
traceId,
|
||||
scores,
|
||||
emptySelectedConfigIds,
|
||||
setEmptySelectedConfigIds,
|
||||
observationId,
|
||||
projectId,
|
||||
variant = "button",
|
||||
@@ -138,6 +139,55 @@ export function AnnotateDrawer({
|
||||
}: {
|
||||
traceId: string;
|
||||
scores: APIScore[];
|
||||
emptySelectedConfigIds: string[];
|
||||
setEmptySelectedConfigIds: (ids: string[]) => void;
|
||||
observationId?: string;
|
||||
projectId: string;
|
||||
variant?: "button" | "badge";
|
||||
type?: "trace" | "observation" | "session";
|
||||
source?: "TraceDetail" | "SessionDetail";
|
||||
}) {
|
||||
const configsData = api.scoreConfigs.all.useQuery({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const configs = configsData.data?.configs ?? [];
|
||||
|
||||
if (configsData.isLoading) return null;
|
||||
|
||||
return (
|
||||
<AnnotateDrawerInner
|
||||
traceId={traceId}
|
||||
scores={scores}
|
||||
configs={configs}
|
||||
emptySelectedConfigIds={emptySelectedConfigIds}
|
||||
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
|
||||
observationId={observationId}
|
||||
projectId={projectId}
|
||||
variant={variant}
|
||||
type={type}
|
||||
source={source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnotateDrawerInner({
|
||||
traceId,
|
||||
scores,
|
||||
configs,
|
||||
emptySelectedConfigIds,
|
||||
setEmptySelectedConfigIds,
|
||||
observationId,
|
||||
projectId,
|
||||
variant = "button",
|
||||
type = "trace",
|
||||
source = "TraceDetail",
|
||||
}: {
|
||||
traceId: string;
|
||||
scores: APIScore[];
|
||||
configs: ValidatedScoreConfig[];
|
||||
emptySelectedConfigIds: string[];
|
||||
setEmptySelectedConfigIds: (ids: string[]) => void;
|
||||
observationId?: string;
|
||||
projectId: string;
|
||||
variant?: "button" | "badge";
|
||||
@@ -150,25 +200,15 @@ export function AnnotateDrawer({
|
||||
scope: "scores:CUD",
|
||||
});
|
||||
|
||||
const configsData = api.scoreConfigs.all.useQuery({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const configs = configsData.data?.configs ?? [];
|
||||
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const form = useForm<AnnotateFormSchemaType>({
|
||||
resolver: zodResolver(AnnotateFormSchema),
|
||||
defaultValues: {
|
||||
scoreData: getDefaultScoreData({
|
||||
scores,
|
||||
traceId,
|
||||
observationId,
|
||||
emptySelectedConfigIds,
|
||||
configs,
|
||||
traceId,
|
||||
observationId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -180,6 +220,25 @@ export function AnnotateDrawer({
|
||||
name: "scoreData",
|
||||
});
|
||||
|
||||
const prevEmptySelectedConfigIdsRef = useRef(emptySelectedConfigIds);
|
||||
|
||||
useEffect(() => {
|
||||
// Only reset the form if emptySelectedConfigIds has changed
|
||||
if (prevEmptySelectedConfigIdsRef.current !== emptySelectedConfigIds) {
|
||||
form.reset({
|
||||
scoreData: getDefaultScoreData({
|
||||
scores,
|
||||
emptySelectedConfigIds,
|
||||
configs,
|
||||
traceId,
|
||||
observationId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
prevEmptySelectedConfigIdsRef.current = emptySelectedConfigIds;
|
||||
}, [emptySelectedConfigIds, scores, configs, traceId, observationId, form]);
|
||||
|
||||
const mutDeleteScore = api.scores.deleteAnnotationScore.useMutation({
|
||||
onSettled: async (data, error) => {
|
||||
if (!data || error) return;
|
||||
|
||||
@@ -287,8 +287,7 @@ export function CreateScoreConfigButton({ projectId }: { projectId: string }) {
|
||||
? "category"
|
||||
: "boolean"
|
||||
} label is mapped to an integer value internally.`}
|
||||
size="xs"
|
||||
></DocPopup>
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormLabel>Label</FormLabel>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,9 @@ import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { useDebounce } from "@/src/hooks/useDebounce";
|
||||
|
||||
type PromptTableRow = {
|
||||
name: string;
|
||||
@@ -44,6 +47,9 @@ export function PromptTable() {
|
||||
|
||||
const [filterState, setFilterState] = useQueryFilterState([], "prompts");
|
||||
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange("All time");
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "createdAt",
|
||||
order: "DESC",
|
||||
@@ -53,12 +59,24 @@ export function PromptTable() {
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const dateRangeFilter: FilterState = dateRange
|
||||
? [
|
||||
{
|
||||
column: "createdAt",
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
value: dateRange.from,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const combinedFilterState = filterState.concat(dateRangeFilter);
|
||||
const prompts = api.prompts.all.useQuery(
|
||||
{
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId: projectId as string, // Typecast as query is enabled only when projectId is present
|
||||
filter: filterState,
|
||||
filter: combinedFilterState,
|
||||
orderBy: orderByState,
|
||||
},
|
||||
{
|
||||
@@ -135,21 +153,22 @@ export function PromptTable() {
|
||||
header: "Name",
|
||||
id: "name",
|
||||
enableSorting: true,
|
||||
size: 250,
|
||||
cell: (row) => {
|
||||
const name = row.getValue();
|
||||
return name ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/prompts/${encodeURIComponent(name)}`}
|
||||
value={name}
|
||||
truncateAt={50}
|
||||
/>
|
||||
) : undefined;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("version", {
|
||||
header: "Latest Version",
|
||||
header: "Versions",
|
||||
id: "version",
|
||||
enableSorting: true,
|
||||
size: 70,
|
||||
cell: (row) => {
|
||||
return row.getValue();
|
||||
},
|
||||
@@ -158,6 +177,7 @@ export function PromptTable() {
|
||||
header: "Type",
|
||||
id: "type",
|
||||
enableSorting: true,
|
||||
size: 60,
|
||||
cell: (row) => {
|
||||
return row.getValue();
|
||||
},
|
||||
@@ -166,6 +186,7 @@ export function PromptTable() {
|
||||
header: "Latest Version Created At",
|
||||
id: "createdAt",
|
||||
enableSorting: true,
|
||||
size: 200,
|
||||
cell: (row) => {
|
||||
const createdAt = row.getValue();
|
||||
return createdAt.toLocaleString();
|
||||
@@ -173,6 +194,7 @@ export function PromptTable() {
|
||||
}),
|
||||
columnHelper.accessor("numberOfObservations", {
|
||||
header: "Number of Generations",
|
||||
size: 170,
|
||||
cell: (row) => {
|
||||
const numberOfObservations = row.getValue();
|
||||
const name = row.row.original.name;
|
||||
@@ -194,6 +216,7 @@ export function PromptTable() {
|
||||
header: "Tags",
|
||||
id: "tags",
|
||||
enableSorting: true,
|
||||
size: 120,
|
||||
cell: (row) => {
|
||||
const tags = row.getValue();
|
||||
const promptName: string = row.row.original.name;
|
||||
@@ -206,7 +229,7 @@ export function PromptTable() {
|
||||
promptsFilter={{
|
||||
...filterOptionTags,
|
||||
projectId: projectId as string,
|
||||
filter: filterState,
|
||||
filter: combinedFilterState,
|
||||
orderBy: orderByState,
|
||||
}}
|
||||
/>
|
||||
@@ -217,6 +240,7 @@ export function PromptTable() {
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: 70,
|
||||
cell: (row) => {
|
||||
const name = row.row.original.name;
|
||||
return <DeletePrompt promptName={name} />;
|
||||
@@ -232,7 +256,9 @@ export function PromptTable() {
|
||||
promptFilterOptions.data,
|
||||
)}
|
||||
filterState={filterState}
|
||||
setFilterState={setFilterState}
|
||||
setFilterState={useDebounce(setFilterState)}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
actionButtons={
|
||||
<Link href={`/project/${projectId}/prompts/new`}>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## Caching Strategy of Prompts
|
||||
|
||||
The caching strategy for prompts is implemented in the `PromptService` class and is utilized in the `createPrompt` function. Here is an overview of how the caching mechanism works:
|
||||
|
||||
### Cache Structure
|
||||
|
||||
The cache for prompts is managed using Redis. The cache key looks like the following: `prompt:<project-id>:<prompt-name>:<prompt-<version ?? label>` This means that for each prompt name we have multiple keys in Redis. Also, if a prompt has multiple labels, it will appear in the cache multiple times.
|
||||
|
||||
### Creation and updates of prompts
|
||||
|
||||
We never update prompts in the cache. Instead, we remove all cache entries for a prompt name of a project when a prompt is updated. This ensures that the cache is always up-to-date with the database.
|
||||
For this, we first acquire a lock in Redis, invalidate the cache, execute the operation in Postgres, and then release the lock.
|
||||
|
||||
### Reading prompts
|
||||
|
||||
When reading prompts, we check whether a lock exists. If it does not, we proceed to read the prompt from the cache. Thereby, we reset the ttl of the cache entry to ensure it remains in the cache.
|
||||
If the lock exists, or the entry is not in Redis, we read the prompt from Postgres and store it in the cache.
|
||||
@@ -8,6 +8,7 @@ import { type PrismaClient } from "@langfuse/shared/src/db";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
|
||||
import { updatePromptTagsOnAllVersions } from "@/src/features/prompts/server/utils/updatePromptTags";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
|
||||
export type CreatePromptParams = CreatePromptTRPCType & {
|
||||
createdBy: string;
|
||||
@@ -82,7 +83,16 @@ export const createPrompt = async ({
|
||||
})),
|
||||
);
|
||||
|
||||
// Lock and invalidate cache for _all_ versions and labels of the prompt name
|
||||
const promptService = new PromptService(prisma, redis);
|
||||
await promptService.lockCache({ projectId, promptName: name });
|
||||
await promptService.invalidateCache({ projectId, promptName: name });
|
||||
|
||||
// Create prompt and update previous prompt versions
|
||||
const [createdPrompt] = await prisma.$transaction(create);
|
||||
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName: name });
|
||||
|
||||
return createdPrompt;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import {
|
||||
LangfuseNotFoundError,
|
||||
InvalidRequestError,
|
||||
type Prompt,
|
||||
} from "@langfuse/shared";
|
||||
import { InvalidRequestError, type Prompt } from "@langfuse/shared";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
type GetPromptByNameParams = {
|
||||
promptName: string;
|
||||
@@ -17,90 +15,35 @@ export const getPromptByName = async (
|
||||
params: GetPromptByNameParams,
|
||||
): Promise<Prompt | null> => {
|
||||
const { promptName, projectId, version, label } = params;
|
||||
const promptService = new PromptService(
|
||||
prisma,
|
||||
redis,
|
||||
Sentry.metrics.increment,
|
||||
);
|
||||
|
||||
if (version && label)
|
||||
throw new InvalidRequestError("Cannot specify both version and label");
|
||||
|
||||
if (version) return getPromptByVersion({ projectId, promptName, version });
|
||||
if (version)
|
||||
return promptService.getPrompt({
|
||||
projectId,
|
||||
promptName,
|
||||
version,
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
if (label) return getPromptByLabel({ projectId, promptName, label });
|
||||
if (label)
|
||||
return promptService.getPrompt({
|
||||
projectId,
|
||||
promptName,
|
||||
label,
|
||||
version: undefined,
|
||||
});
|
||||
|
||||
return getProductionPrompt(params);
|
||||
};
|
||||
|
||||
const getProductionPrompt = async ({
|
||||
promptName,
|
||||
projectId,
|
||||
}: {
|
||||
promptName: string;
|
||||
projectId: string;
|
||||
}): Promise<Prompt> => {
|
||||
const productionPrompt = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
name: promptName,
|
||||
labels: {
|
||||
has: PRODUCTION_LABEL,
|
||||
},
|
||||
},
|
||||
return promptService.getPrompt({
|
||||
projectId,
|
||||
promptName,
|
||||
label: PRODUCTION_LABEL,
|
||||
version: undefined,
|
||||
});
|
||||
|
||||
if (!productionPrompt)
|
||||
throw new LangfuseNotFoundError(
|
||||
`No production-labeled prompt found with name '${promptName}' in project ${projectId}`,
|
||||
);
|
||||
|
||||
return productionPrompt;
|
||||
};
|
||||
|
||||
const getPromptByVersion = async ({
|
||||
promptName,
|
||||
projectId,
|
||||
version,
|
||||
}: {
|
||||
promptName: string;
|
||||
projectId: string;
|
||||
version: number;
|
||||
}): Promise<Prompt> => {
|
||||
const prompt = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
name: promptName,
|
||||
version: version,
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt)
|
||||
throw new LangfuseNotFoundError(
|
||||
`No prompt found with name '${promptName}' in project ${projectId} with version ${version}`,
|
||||
);
|
||||
|
||||
return prompt;
|
||||
};
|
||||
|
||||
const getPromptByLabel = async ({
|
||||
promptName,
|
||||
projectId,
|
||||
label,
|
||||
}: {
|
||||
promptName: string;
|
||||
projectId: string;
|
||||
label: string;
|
||||
}): Promise<Prompt> => {
|
||||
const prompt = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
name: promptName,
|
||||
labels: {
|
||||
has: label,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt)
|
||||
throw new LangfuseNotFoundError(
|
||||
`No prompt found with name '${promptName}' in project ${projectId} with label ${label}`,
|
||||
);
|
||||
|
||||
return prompt;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { GetPromptByNameSchema } from "@/src/features/prompts/server/utils/valid
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
|
||||
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
|
||||
const getPromptNameHandler = async (
|
||||
req: NextApiRequest,
|
||||
@@ -20,6 +22,18 @@ const getPromptNameHandler = async (
|
||||
label,
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
let errorMessage = `Prompt not found: '${promptName}'`;
|
||||
|
||||
if (version) {
|
||||
errorMessage += ` with version ${version}`;
|
||||
} else {
|
||||
errorMessage += ` with label '${label ?? PRODUCTION_LABEL}'`;
|
||||
}
|
||||
|
||||
throw new LangfuseNotFoundError(errorMessage);
|
||||
}
|
||||
|
||||
return res.status(200).json(prompt);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { type Prompt, Prisma } from "@langfuse/shared/src/db";
|
||||
|
||||
import { createPrompt } from "../actions/createPrompt";
|
||||
import { orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { observationsTableCols, orderByToPrismaSql } from "@langfuse/shared";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { optionalPaginationZod, paginationZod } from "@langfuse/shared";
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import { PromptService, redis } from "@langfuse/shared/src/server";
|
||||
|
||||
const PromptFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
@@ -248,16 +249,18 @@ export const promptRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { projectId, promptName } = input;
|
||||
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
// fetch prompts before deletion to enable audit logging
|
||||
const prompts = await ctx.prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
name: input.promptName,
|
||||
},
|
||||
});
|
||||
@@ -275,14 +278,23 @@ export const promptRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
// Lock and invalidate cache for _all_ versions and labels of the prompt
|
||||
const promptService = new PromptService(ctx.prisma, redis);
|
||||
await promptService.lockCache({ projectId, promptName });
|
||||
await promptService.invalidateCache({ projectId, promptName });
|
||||
|
||||
// Delete all prompts with the given name
|
||||
await ctx.prisma.prompt.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
id: {
|
||||
in: prompts.map((p) => p.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
@@ -296,19 +308,22 @@ export const promptRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { projectId } = input;
|
||||
|
||||
try {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
const promptVersion = await ctx.prisma.prompt.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.promptVersionId,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
const { name: promptName } = promptVersion;
|
||||
|
||||
await auditLog(
|
||||
{
|
||||
@@ -325,7 +340,7 @@ export const promptRouter = createTRPCRouter({
|
||||
ctx.prisma.prompt.delete({
|
||||
where: {
|
||||
id: input.promptVersionId,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -334,8 +349,8 @@ export const promptRouter = createTRPCRouter({
|
||||
if (promptVersion.labels.includes(LATEST_PROMPT_LABEL)) {
|
||||
const newLatestPrompt = await ctx.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
name: promptVersion.name,
|
||||
projectId,
|
||||
name: promptName,
|
||||
id: { not: input.promptVersionId },
|
||||
},
|
||||
orderBy: [{ version: "desc" }],
|
||||
@@ -358,7 +373,16 @@ export const promptRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Lock and invalidate cache for _all_ versions and labels of the prompt
|
||||
const promptService = new PromptService(ctx.prisma, redis);
|
||||
await promptService.lockCache({ projectId, promptName });
|
||||
await promptService.invalidateCache({ projectId, promptName });
|
||||
|
||||
// Execute transaction
|
||||
await ctx.prisma.$transaction(transaction);
|
||||
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
@@ -374,19 +398,22 @@ export const promptRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { projectId } = input;
|
||||
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
scope: "prompts:CUD",
|
||||
});
|
||||
|
||||
const toBeLabeledPrompt = await ctx.prisma.prompt.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.promptId,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const { name: promptName } = toBeLabeledPrompt;
|
||||
const newLabels = [...new Set(input.labels)];
|
||||
|
||||
await auditLog(
|
||||
@@ -405,8 +432,8 @@ export const promptRouter = createTRPCRouter({
|
||||
|
||||
const previousLabeledPrompts = await ctx.prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
name: toBeLabeledPrompt.name,
|
||||
projectId,
|
||||
name: promptName,
|
||||
labels: { hasSome: newLabels },
|
||||
id: { not: input.promptId },
|
||||
},
|
||||
@@ -417,7 +444,7 @@ export const promptRouter = createTRPCRouter({
|
||||
ctx.prisma.prompt.update({
|
||||
where: {
|
||||
id: toBeLabeledPrompt.id,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
labels: newLabels,
|
||||
@@ -431,7 +458,7 @@ export const promptRouter = createTRPCRouter({
|
||||
ctx.prisma.prompt.update({
|
||||
where: {
|
||||
id: prevPrompt.id,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
labels: prevPrompt.labels.filter((l) => !newLabels.includes(l)),
|
||||
@@ -439,7 +466,17 @@ export const promptRouter = createTRPCRouter({
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Lock and invalidate cache for _all_ versions and labels of the prompt
|
||||
const promptService = new PromptService(ctx.prisma, redis);
|
||||
await promptService.lockCache({ projectId, promptName });
|
||||
await promptService.invalidateCache({ projectId, promptName });
|
||||
|
||||
// Execute transaction
|
||||
await ctx.prisma.$transaction(toBeExecuted);
|
||||
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
@@ -472,23 +509,32 @@ export const promptRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { projectId, name: promptName } = input;
|
||||
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
projectId,
|
||||
scope: "objects:tag",
|
||||
});
|
||||
|
||||
try {
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "prompt",
|
||||
resourceId: input.name,
|
||||
resourceId: promptName,
|
||||
action: "updateTags",
|
||||
after: input.tags,
|
||||
});
|
||||
|
||||
// Lock and invalidate cache for _all_ versions and labels of the prompt
|
||||
const promptService = new PromptService(ctx.prisma, redis);
|
||||
await promptService.lockCache({ projectId, promptName });
|
||||
await promptService.invalidateCache({ projectId, promptName });
|
||||
|
||||
await ctx.prisma.prompt.updateMany({
|
||||
where: {
|
||||
name: input.name,
|
||||
projectId: input.projectId,
|
||||
name: promptName,
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
tags: {
|
||||
@@ -496,6 +542,9 @@ export const promptRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Unlock cache
|
||||
await promptService.unlockCache({ projectId, promptName });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -570,6 +619,7 @@ export const promptRouter = createTRPCRouter({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
promptIds: z.array(z.string()),
|
||||
filter: z.array(singleFilter).nullish(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
@@ -580,7 +630,11 @@ export const promptRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (input.promptIds.length === 0) return [];
|
||||
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
input.filter ?? [],
|
||||
observationsTableCols,
|
||||
"prompts",
|
||||
);
|
||||
const metrics = await ctx.prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
@@ -598,18 +652,19 @@ export const promptRouter = createTRPCRouter({
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
count(*) AS "observationCount",
|
||||
MIN(ov.start_time) AS "firstUsed",
|
||||
MAX(ov.start_time) AS "lastUsed",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY ov.completion_tokens) AS "medianOutputTokens",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY ov.prompt_tokens) AS "medianInputTokens",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY ov.calculated_total_cost) AS "medianTotalCost",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY ov.latency) AS "medianLatency"
|
||||
MIN(o.start_time) AS "firstUsed",
|
||||
MAX(o.start_time) AS "lastUsed",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.completion_tokens) AS "medianOutputTokens",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.prompt_tokens) AS "medianInputTokens",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.calculated_total_cost) AS "medianTotalCost",
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY o.latency) AS "medianLatency"
|
||||
FROM
|
||||
"observations_view" ov
|
||||
"observations_view" o
|
||||
WHERE
|
||||
ov.prompt_id = p.id
|
||||
o.prompt_id = p.id
|
||||
AND "type" = 'GENERATION'
|
||||
AND "project_id" = ${input.projectId}
|
||||
${filterCondition}
|
||||
) AS observation_metrics ON true
|
||||
WHERE "project_id" = ${input.projectId}
|
||||
AND p.id in (${Prisma.join(input.promptIds)})
|
||||
@@ -639,6 +694,7 @@ export const promptRouter = createTRPCRouter({
|
||||
AND o.prompt_id IS NOT NULL
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND p.id IN (${Prisma.join(input.promptIds)})
|
||||
${filterCondition}
|
||||
GROUP BY 1,2
|
||||
ORDER BY 1,2
|
||||
),
|
||||
@@ -677,6 +733,7 @@ export const promptRouter = createTRPCRouter({
|
||||
AND o.type = 'GENERATION'
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND o.prompt_id IN (${Prisma.join(input.promptIds)})
|
||||
${filterCondition}
|
||||
GROUP BY
|
||||
o.prompt_id,
|
||||
o.trace_id
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
|
||||
import { ApiAuthService } from "@/src/features/public-api/server/apiAuth";
|
||||
import { type NextApiRequest } from "next";
|
||||
import { UnauthorizedError, ForbiddenError } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
|
||||
export async function authorizePromptRequestOrThrow(req: NextApiRequest) {
|
||||
const authCheck = await verifyAuthHeaderAndReturnScope(
|
||||
req.headers.authorization,
|
||||
);
|
||||
const authCheck = await new ApiAuthService(
|
||||
prisma,
|
||||
redis,
|
||||
).verifyAuthHeaderAndReturnScope(req.headers.authorization);
|
||||
if (!authCheck.validKey) throw new UnauthorizedError(authCheck.error);
|
||||
if (authCheck.scope.accessLevel !== "all")
|
||||
throw new ForbiddenError(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user