Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae508d820 | ||
|
|
a6e581d349 | ||
|
|
9e77738a2f | ||
|
|
d15c0a27f5 | ||
|
|
a8626841b5 | ||
|
|
5945a93b7d | ||
|
|
9c447de584 | ||
|
|
48df0e04de | ||
|
|
0897ac753b | ||
|
|
d00c63100e | ||
|
|
e8970bc11c | ||
|
|
eecb253093 | ||
|
|
4e4fbb5101 | ||
|
|
bb4823aa27 | ||
|
|
174d96c100 | ||
|
|
8e59b544c9 | ||
|
|
cffd84f0e7 | ||
|
|
845faee385 | ||
|
|
e45c4af30f | ||
|
|
616544a48c | ||
|
|
75d64b38b3 | ||
|
|
65cdd75282 | ||
|
|
2744028f4c | ||
|
|
3c02c54de2 | ||
|
|
974d2c1007 | ||
|
|
008045c771 | ||
|
|
c6790c6c0e | ||
|
|
909445ce5b | ||
|
|
f4ed4a2635 | ||
|
|
d6206b5269 | ||
|
|
62fa43aa78 | ||
|
|
3c9239a4ab | ||
|
|
476cddb4e1 | ||
|
|
a6c1678da8 | ||
|
|
129805ebde | ||
|
|
59eafb42c3 | ||
|
|
e4695ac717 | ||
|
|
eebd6ea0e9 | ||
|
|
b4a8b08d3d | ||
|
|
ed44b663ff | ||
|
|
720ebe861e | ||
|
|
9a6f8fd75d | ||
|
|
f0da1cfa30 | ||
|
|
0c9ebb3da7 | ||
|
|
3d16d1ac57 | ||
|
|
fa462a8bc4 | ||
|
|
695b65fe0d | ||
|
|
955388e846 | ||
|
|
c6864ed8d9 | ||
|
|
917516fa91 | ||
|
|
19017a6709 | ||
|
|
ca5e6569f9 | ||
|
|
101942b98b | ||
|
|
1188a21b02 | ||
|
|
ec72dd840d |
+31
-31
@@ -172,7 +172,32 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# LANGFUSE_INIT_USER_NAME=User Name
|
||||
# LANGFUSE_INIT_USER_PASSWORD=password
|
||||
|
||||
# Redis configuration
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
|
||||
# Clickhouse configuration
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_CLUSTER_NAME=default
|
||||
# CLICKHOUSE_DB=default
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_CLUSTER_ENABLED=true
|
||||
|
||||
# Ingestion configuration
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
|
||||
### START Enterprise Edition Configuration
|
||||
|
||||
@@ -241,19 +266,8 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
# Redis
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
# Cache configuration
|
||||
# LANGFUSE_CACHE_API_KEY_ENABLED=
|
||||
# LANGFUSE_CACHE_API_KEY_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_PROMPT_ENABLED=
|
||||
# LANGFUSE_CACHE_PROMPT_TTL_SECONDS=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
|
||||
|
||||
# Rate limiting
|
||||
# LANGFUSE_RATE_LIMITS_ENABLED=
|
||||
@@ -262,27 +276,13 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# STRIPE_SECRET_KEY=
|
||||
# STRIPE_WEBHOOK_SIGNING_SECRET=
|
||||
|
||||
# Betterstack Status Page
|
||||
# BETTERSTACK_UPTIME_API_KEY=
|
||||
# BETTERSTACK_UPTIME_STATUS_PAGE_ID=
|
||||
|
||||
### END Langfuse Cloud Config
|
||||
|
||||
|
||||
### START Langfuse V3 Ingestion
|
||||
|
||||
# Clickhouse
|
||||
# CLICKHOUSE_MIGRATION_URL=
|
||||
# CLICKHOUSE_URL=
|
||||
# CLICKHOUSE_USER=
|
||||
# CLICKHOUSE_PASSWORD=
|
||||
# CLICKHOUSE_DB=
|
||||
|
||||
# Ingestion
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
|
||||
|
||||
### END Langfuse V3 Ingestion
|
||||
|
||||
|
||||
### START Langfuse CI Config
|
||||
|
||||
# LANGFUSE_INIT_ORG_CLOUD_PLAN=
|
||||
|
||||
@@ -56,6 +56,12 @@ jobs:
|
||||
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
|
||||
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
|
||||
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_APPLICATION_ID=${{ vars.NEXT_PUBLIC_DATADOG_APPLICATION_ID }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=${{ vars.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_SITE=${{ vars.NEXT_PUBLIC_DATADOG_SITE }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_ENVIRONMENT=${{ vars.NEXT_PUBLIC_DATADOG_ENVIRONMENT }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE=${{ vars.NEXT_PUBLIC_DATADOG_SESSION_SAMPLE_RATE }} \
|
||||
--build-arg NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE=${{ vars.NEXT_PUBLIC_DATADOG_SESSION_REPLAY_SAMPLE_RATE }} \
|
||||
.
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
- name: Render AWS ECS Task Definition
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.18.3"
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
name: tests-web-sync (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ["20.18.3"]
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ["20.18.3"]
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
blob-provider: ["", "-azure"]
|
||||
steps:
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ["20.18.3"]
|
||||
node-version: [20]
|
||||
postgres-version: [12, 15]
|
||||
blob-provider: ["", "-azure"]
|
||||
steps:
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.18.3"
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Login to Docker Hub
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
version: 9.5.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.18.3"
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: install dependencies
|
||||
@@ -459,7 +459,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.18.3"
|
||||
node-version: 20
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale. Please reopen if the issue persists."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.8.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.25",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -400,8 +400,8 @@ types:
|
||||
prompt_tokens: integer
|
||||
completion_tokens: integer
|
||||
total_tokens: integer
|
||||
prompt_tokens_details: optional<map<string, integer>>
|
||||
completion_tokens_details: optional<map<string, integer>>
|
||||
prompt_tokens_details: optional<map<string, optional<integer>>>
|
||||
completion_tokens_details: optional<map<string, optional<integer>>>
|
||||
|
||||
OpenAIResponseUsageSchema:
|
||||
docs: OpenAI Usage schema from Response API
|
||||
@@ -409,8 +409,8 @@ types:
|
||||
input_tokens: integer
|
||||
output_tokens: integer
|
||||
total_tokens: integer
|
||||
input_tokens_details: optional<map<string, integer>>
|
||||
output_tokens_details: optional<map<string, integer>>
|
||||
input_tokens_details: optional<map<string, optional<integer>>>
|
||||
output_tokens_details: optional<map<string, optional<integer>>>
|
||||
|
||||
UsageDetails:
|
||||
discriminated: false
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"langchain": "^0.3.15",
|
||||
"langfuse-langchain": "3.30.3",
|
||||
"lodash": "^4.17.21",
|
||||
"lossless-json": "^4.0.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.9.15",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
|
||||
@@ -80,6 +80,12 @@ export const JobExecutionStatus = {
|
||||
CANCELLED: "CANCELLED"
|
||||
} as const;
|
||||
export type JobExecutionStatus = (typeof JobExecutionStatus)[keyof typeof JobExecutionStatus];
|
||||
export const BlobStorageIntegrationType = {
|
||||
S3: "S3",
|
||||
S3_COMPATIBLE: "S3_COMPATIBLE",
|
||||
AZURE_BLOB_STORAGE: "AZURE_BLOB_STORAGE"
|
||||
} as const;
|
||||
export type BlobStorageIntegrationType = (typeof BlobStorageIntegrationType)[keyof typeof BlobStorageIntegrationType];
|
||||
export type Account = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -188,6 +194,23 @@ export type BillingMeterBackup = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type BlobStorageIntegration = {
|
||||
project_id: string;
|
||||
type: BlobStorageIntegrationType;
|
||||
bucket_name: string;
|
||||
prefix: string;
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
region: string;
|
||||
endpoint: string | null;
|
||||
force_path_style: boolean;
|
||||
next_sync_at: Timestamp | null;
|
||||
last_sync_at: Timestamp | null;
|
||||
enabled: boolean;
|
||||
export_frequency: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type Comment = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -502,13 +525,6 @@ export type PromptDependency = {
|
||||
child_label: string | null;
|
||||
child_version: number | null;
|
||||
};
|
||||
export type QueueBackUp = {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
queue_name: string;
|
||||
content: unknown;
|
||||
created_at: Generated<Timestamp>;
|
||||
};
|
||||
export type ScoreConfig = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -579,6 +595,7 @@ export type DB = {
|
||||
background_migrations: BackgroundMigration;
|
||||
batch_exports: BatchExport;
|
||||
billing_meter_backups: BillingMeterBackup;
|
||||
blob_storage_integrations: BlobStorageIntegration;
|
||||
comments: Comment;
|
||||
cron_jobs: CronJobs;
|
||||
dataset_items: DatasetItem;
|
||||
@@ -602,7 +619,6 @@ export type DB = {
|
||||
projects: Project;
|
||||
prompt_dependencies: PromptDependency;
|
||||
prompts: Prompt;
|
||||
queue_backups: QueueBackUp;
|
||||
score_configs: ScoreConfig;
|
||||
scores: LegacyPrismaScore;
|
||||
Session: Session;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropTable
|
||||
DROP TABLE "queue_backups";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BlobStorageIntegrationType" AS ENUM ('S3', 'S3_COMPATIBLE', 'AZURE_BLOB_STORAGE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "blob_storage_integrations" (
|
||||
"project_id" TEXT NOT NULL,
|
||||
"type" "BlobStorageIntegrationType" NOT NULL,
|
||||
"bucket_name" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"access_key_id" TEXT NOT NULL,
|
||||
"secret_access_key" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL,
|
||||
"endpoint" TEXT,
|
||||
"force_path_style" BOOLEAN NOT NULL,
|
||||
"next_sync_at" TIMESTAMP(3),
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"enabled" BOOLEAN NOT NULL,
|
||||
"export_frequency" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "blob_storage_integrations_pkey" PRIMARY KEY ("project_id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "blob_storage_integrations" ADD CONSTRAINT "blob_storage_integrations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -109,38 +109,39 @@ model Organization {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
name String
|
||||
retentionDays Int? @map("retention_days")
|
||||
projectMembers ProjectMembership[]
|
||||
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
apiKeys ApiKey[]
|
||||
dataset Dataset[]
|
||||
invitations MembershipInvitation[]
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
Model Model[]
|
||||
EvalTemplate EvalTemplate[]
|
||||
JobConfiguration JobConfiguration[]
|
||||
JobExecution JobExecution[]
|
||||
LlmApiKeys LlmApiKeys[]
|
||||
PosthogIntegration PosthogIntegration[]
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
comment Comment[]
|
||||
annotationQueue AnnotationQueue[]
|
||||
annotationQueueItem AnnotationQueueItem[]
|
||||
TraceMedia TraceMedia[]
|
||||
Media Media[]
|
||||
ObservationMedia ObservationMedia[]
|
||||
LegacyTrace LegacyPrismaTrace[]
|
||||
LegacyObservation LegacyPrismaObservation[]
|
||||
LegacyScore LegacyPrismaScore[]
|
||||
PromptDependency PromptDependency[]
|
||||
id String @id @default(cuid())
|
||||
orgId String @map("org_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
name String
|
||||
retentionDays Int? @map("retention_days")
|
||||
projectMembers ProjectMembership[]
|
||||
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
|
||||
apiKeys ApiKey[]
|
||||
dataset Dataset[]
|
||||
invitations MembershipInvitation[]
|
||||
sessions TraceSession[]
|
||||
Prompt Prompt[]
|
||||
Model Model[]
|
||||
EvalTemplate EvalTemplate[]
|
||||
JobConfiguration JobConfiguration[]
|
||||
JobExecution JobExecution[]
|
||||
LlmApiKeys LlmApiKeys[]
|
||||
PosthogIntegration PosthogIntegration[]
|
||||
BlobStorageIntegration BlobStorageIntegration[]
|
||||
scoreConfig ScoreConfig[]
|
||||
BatchExport BatchExport[]
|
||||
comment Comment[]
|
||||
annotationQueue AnnotationQueue[]
|
||||
annotationQueueItem AnnotationQueueItem[]
|
||||
TraceMedia TraceMedia[]
|
||||
Media Media[]
|
||||
ObservationMedia ObservationMedia[]
|
||||
LegacyTrace LegacyPrismaTrace[]
|
||||
LegacyObservation LegacyPrismaObservation[]
|
||||
LegacyScore LegacyPrismaScore[]
|
||||
PromptDependency PromptDependency[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
@@ -675,8 +676,8 @@ model PromptDependency {
|
||||
childLabel String? @map("child_label")
|
||||
childVersion Int? @map("child_version")
|
||||
|
||||
@@index([projectId, parentId])
|
||||
@@index([projectId, childName])
|
||||
@@index([projectId, parentId], map: "prompt_dependencies_project_id_parent_id")
|
||||
@@index([projectId, childName], map: "prompt_dependencies_project_id_child_name")
|
||||
@@map("prompt_dependencies")
|
||||
}
|
||||
|
||||
@@ -860,7 +861,6 @@ model SsoConfig {
|
||||
authConfig Json? @map("auth_config")
|
||||
// e.g. { "clientId": "1234", "clientSecret": "5678" }, null if credentials from env should be used
|
||||
// secrets like clientSecret are encrypted on the application level
|
||||
|
||||
@@map("sso_configs")
|
||||
}
|
||||
|
||||
@@ -876,6 +876,40 @@ model PosthogIntegration {
|
||||
@@map("posthog_integrations")
|
||||
}
|
||||
|
||||
model BlobStorageIntegration {
|
||||
projectId String @id @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Blob Storage Config
|
||||
type BlobStorageIntegrationType @map("type")
|
||||
bucketName String @map("bucket_name")
|
||||
prefix String @map("prefix")
|
||||
accessKeyId String @map("access_key_id")
|
||||
secretAccessKey String @map("secret_access_key")
|
||||
region String @map("region")
|
||||
endpoint String? @map("endpoint")
|
||||
forcePathStyle Boolean @map("force_path_style")
|
||||
|
||||
// Integration Config
|
||||
nextSyncAt DateTime? @map("next_sync_at")
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
enabled Boolean
|
||||
exportFrequency String @map("export_frequency")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@map("blob_storage_integrations")
|
||||
}
|
||||
|
||||
enum BlobStorageIntegrationType {
|
||||
S3
|
||||
S3_COMPATIBLE
|
||||
AZURE_BLOB_STORAGE
|
||||
|
||||
@@map("BlobStorageIntegrationType")
|
||||
}
|
||||
|
||||
model BatchExport {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -953,17 +987,6 @@ model ObservationMedia {
|
||||
@@map("observation_media")
|
||||
}
|
||||
|
||||
model QueueBackUp {
|
||||
id String @id @default(cuid())
|
||||
projectId String? @map("project_id")
|
||||
queueName String @map("queue_name")
|
||||
content Json
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("queue_backups")
|
||||
}
|
||||
|
||||
model BillingMeterBackup {
|
||||
// unique
|
||||
stripeCustomerId String @map("stripe_customer_id")
|
||||
|
||||
@@ -10,7 +10,7 @@ const EnvSchema = z.object({
|
||||
REDIS_PORT: z.coerce
|
||||
.number({
|
||||
description:
|
||||
".env files convert numbers to strings, therefoore we have to enforce them to be numbers",
|
||||
".env files convert numbers to strings, therefore we have to enforce them to be numbers",
|
||||
})
|
||||
.positive()
|
||||
.max(65536, `options.port should be >= 0 and < 65536`)
|
||||
@@ -50,10 +50,7 @@ const EnvSchema = z.object({
|
||||
ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_S3_CONCURRENT_WRITES: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(50),
|
||||
LANGFUSE_S3_CONCURRENT_WRITES: z.coerce.number().positive().default(50),
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
|
||||
required_error: "Langfuse requires a bucket name for S3 Event Uploads.",
|
||||
}),
|
||||
@@ -75,9 +72,9 @@ const EnvSchema = z.object({
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM: z.string().default('email'),
|
||||
LANGFUSE_CUSTOM_SSO_NAME_CLAIM: z.string().default('name'),
|
||||
LANGFUSE_CUSTOM_SSO_SUB_CLAIM: z.string().default('sub'),
|
||||
LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM: z.string().default("email"),
|
||||
LANGFUSE_CUSTOM_SSO_NAME_CLAIM: z.string().default("name"),
|
||||
LANGFUSE_CUSTOM_SSO_SUB_CLAIM: z.string().default("sub"),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -2,6 +2,11 @@ export const ClickhouseTableNames = {
|
||||
traces: "traces",
|
||||
observations: "observations",
|
||||
scores: "scores",
|
||||
|
||||
// Virtual tables for dashboards
|
||||
// TODO: Check if we can do this more elegantly
|
||||
scores_numeric: "scores_numeric",
|
||||
scores_categorical: "scores_categorical",
|
||||
} as const;
|
||||
|
||||
export type ClickhouseTableName = keyof typeof ClickhouseTableNames;
|
||||
|
||||
@@ -17,7 +17,6 @@ export * from "./clickhouse/schemaUtils";
|
||||
export * from "./clickhouse/schema";
|
||||
export * from "./repositories/definitions";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "./ingestion/modelMatch";
|
||||
export * from "./ingestion/processEventBatch";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/validateAndInflateScore";
|
||||
@@ -35,6 +34,8 @@ export * from "./redis/batchActionQueue";
|
||||
export * from "./redis/ingestionQueue";
|
||||
export * from "./redis/postHogIntegrationQueue";
|
||||
export * from "./redis/postHogIntegrationProcessingQueue";
|
||||
export * from "./redis/blobStorageIntegrationQueue";
|
||||
export * from "./redis/blobStorageIntegrationProcessingQueue";
|
||||
export * from "./redis/dataRetentionQueue";
|
||||
export * from "./redis/dataRetentionProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
@@ -50,6 +51,7 @@ export * from "./queries";
|
||||
export * from "./repositories";
|
||||
export * from "./redis/evalExecutionQueue";
|
||||
export * from "./services/sessions-ui-table-service";
|
||||
export * from "./services/datasets-ui-table-service";
|
||||
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Model, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db";
|
||||
import { Observation } from "../repositories";
|
||||
|
||||
export async function findModel(p: {
|
||||
event: {
|
||||
projectId: string;
|
||||
model?: string;
|
||||
unit?: string;
|
||||
startTime?: Date;
|
||||
};
|
||||
existingDbObservation?: Omit<Observation, "input" | "output">;
|
||||
}): Promise<Model | null> {
|
||||
const { event, existingDbObservation } = p;
|
||||
// either get the model from the existing observation
|
||||
// or match pattern on the user provided model name
|
||||
const modelCondition = event.model
|
||||
? Prisma.sql`AND ${event.model} ~ match_pattern`
|
||||
: existingDbObservation?.internalModel
|
||||
? Prisma.sql`AND model_name = ${existingDbObservation.internalModel}`
|
||||
: undefined;
|
||||
if (!modelCondition) return null;
|
||||
|
||||
// unit based on the current event or the existing observation, both can be undefined
|
||||
const mergedUnit = event.unit ?? existingDbObservation?.unit;
|
||||
|
||||
const unitCondition = mergedUnit
|
||||
? Prisma.sql`AND unit = ${mergedUnit}`
|
||||
: Prisma.empty;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
project_id AS "projectId",
|
||||
model_name AS "modelName",
|
||||
match_pattern AS "matchPattern",
|
||||
start_date AS "startDate",
|
||||
input_price AS "inputPrice",
|
||||
output_price AS "outputPrice",
|
||||
total_price AS "totalPrice",
|
||||
unit,
|
||||
tokenizer_id AS "tokenizerId",
|
||||
tokenizer_config AS "tokenizerConfig"
|
||||
FROM
|
||||
models
|
||||
WHERE (project_id = ${event.projectId}
|
||||
OR project_id IS NULL)
|
||||
${modelCondition}
|
||||
${unitCondition}
|
||||
AND (start_date IS NULL OR start_date <= ${
|
||||
event.startTime ? new Date(event.startTime) : new Date()
|
||||
}::timestamp with time zone at time zone 'UTC')
|
||||
ORDER BY
|
||||
project_id ASC,
|
||||
start_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const foundModels = await prisma.$queryRaw<Array<Model>>(sql);
|
||||
|
||||
return foundModels[0] ?? null;
|
||||
}
|
||||
@@ -81,10 +81,10 @@ const OpenAICompletionUsageSchema = z
|
||||
completion_tokens: z.number().int().nonnegative(),
|
||||
total_tokens: z.number().int().nonnegative(),
|
||||
prompt_tokens_details: z
|
||||
.record(z.string(), z.number().int().nonnegative())
|
||||
.record(z.string(), z.number().int().nonnegative().nullish())
|
||||
.nullish(),
|
||||
completion_tokens_details: z
|
||||
.record(z.string(), z.number().int().nonnegative())
|
||||
.record(z.string(), z.number().int().nonnegative().nullish())
|
||||
.nullish(),
|
||||
})
|
||||
.strict()
|
||||
@@ -110,15 +110,19 @@ const OpenAICompletionUsageSchema = z
|
||||
|
||||
if (prompt_tokens_details) {
|
||||
for (const [key, value] of Object.entries(prompt_tokens_details)) {
|
||||
result[`input_${key}`] = value;
|
||||
result.input = Math.max(result.input - (value ?? 0), 0);
|
||||
if (value !== null && value !== undefined) {
|
||||
result[`input_${key}`] = value;
|
||||
result.input = Math.max(result.input - (value ?? 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (completion_tokens_details) {
|
||||
for (const [key, value] of Object.entries(completion_tokens_details)) {
|
||||
result[`output_${key}`] = value;
|
||||
result.output = Math.max(result.output - (value ?? 0), 0);
|
||||
if (value !== null && value !== undefined) {
|
||||
result[`output_${key}`] = value;
|
||||
result.output = Math.max(result.output - (value ?? 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +137,10 @@ const OpenAIResponseUsageSchema = z
|
||||
output_tokens: z.number().int().nonnegative(),
|
||||
total_tokens: z.number().int().nonnegative(),
|
||||
input_tokens_details: z
|
||||
.record(z.string(), z.number().int().nonnegative())
|
||||
.record(z.string(), z.number().int().nonnegative().nullish())
|
||||
.nullish(),
|
||||
output_tokens_details: z
|
||||
.record(z.string(), z.number().int().nonnegative())
|
||||
.record(z.string(), z.number().int().nonnegative().nullish())
|
||||
.nullish(),
|
||||
})
|
||||
.strict()
|
||||
@@ -162,15 +166,19 @@ const OpenAIResponseUsageSchema = z
|
||||
|
||||
if (input_tokens_details) {
|
||||
for (const [key, value] of Object.entries(input_tokens_details)) {
|
||||
result[`input_${key}`] = value;
|
||||
result.input = Math.max(result.input - (value ?? 0), 0);
|
||||
if (value !== null && value !== undefined) {
|
||||
result[`input_${key}`] = value;
|
||||
result.input = Math.max(result.input - (value ?? 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (output_tokens_details) {
|
||||
for (const [key, value] of Object.entries(output_tokens_details)) {
|
||||
result[`output_${key}`] = value;
|
||||
result.output = Math.max(result.output - (value ?? 0), 0);
|
||||
if (value !== null && value !== undefined) {
|
||||
result[`output_${key}`] = value;
|
||||
result.output = Math.max(result.output - (value ?? 0), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,35 +160,49 @@ export const addUserToSpan = (
|
||||
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
|
||||
|
||||
const cloudWatchClient = new CloudWatchClient();
|
||||
const cloudWatchLastSubmitted: Record<string, number> = {};
|
||||
const sendCloudWatchMetric = (key: string, value: number | undefined) => {
|
||||
const currentTime = Date.now();
|
||||
const interval = 30 * 1000;
|
||||
let lastFlushTime = 0;
|
||||
let metricCache: Record<string, number> = {};
|
||||
|
||||
// Check if the function has been executed in the last 30s for this key
|
||||
if (
|
||||
!cloudWatchLastSubmitted[key] ||
|
||||
currentTime - cloudWatchLastSubmitted[key] >= interval
|
||||
) {
|
||||
cloudWatchLastSubmitted[key] = currentTime;
|
||||
cloudWatchClient
|
||||
.send(
|
||||
new PutMetricDataCommand({
|
||||
Namespace: "Langfuse",
|
||||
MetricData: [
|
||||
{
|
||||
MetricName: key,
|
||||
Value: value ?? 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.warn("Failed to send metric to CloudWatch", error);
|
||||
});
|
||||
// Caches metrics and flushes them on schedule
|
||||
const sendCloudWatchMetric = (key: string, value: number, replace: boolean) => {
|
||||
// Store the latest value for each metric key. If replace is false (e.g. for increments) we add the value to the existing value.
|
||||
metricCache[key] = replace ? value : (metricCache[key] || 0) + value;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const flushInterval = 30 * 1000; // 30 seconds
|
||||
|
||||
// Check if it's time to flush the metrics
|
||||
if (currentTime - lastFlushTime >= flushInterval) {
|
||||
flushMetricsToCloudWatch();
|
||||
}
|
||||
};
|
||||
|
||||
// Flush all cached metrics in a single API call
|
||||
const flushMetricsToCloudWatch = () => {
|
||||
if (Object.keys(metricCache).length === 0) return;
|
||||
|
||||
lastFlushTime = Date.now();
|
||||
|
||||
const metricData = Object.entries(metricCache).map(([key, value]) => ({
|
||||
MetricName: key,
|
||||
Value: value,
|
||||
}));
|
||||
|
||||
// Clear the cache after preparing the metrics
|
||||
metricCache = {};
|
||||
|
||||
cloudWatchClient
|
||||
.send(
|
||||
new PutMetricDataCommand({
|
||||
Namespace: "Langfuse",
|
||||
MetricData: metricData,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.warn("Failed to send metrics to CloudWatch", error);
|
||||
});
|
||||
};
|
||||
|
||||
export const recordGauge = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
@@ -199,7 +213,7 @@ export const recordGauge = (
|
||||
| undefined,
|
||||
) => {
|
||||
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
|
||||
sendCloudWatchMetric(stat, value);
|
||||
sendCloudWatchMetric(stat, value ?? 0, true);
|
||||
}
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
@@ -210,7 +224,7 @@ export const recordIncrement = (
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
|
||||
sendCloudWatchMetric(stat, value);
|
||||
sendCloudWatchMetric(stat, value ?? 1, false);
|
||||
}
|
||||
dd.dogstatsd.increment(stat, value, tags);
|
||||
};
|
||||
@@ -220,9 +234,6 @@ export const recordHistogram = (
|
||||
value?: number | undefined,
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
|
||||
sendCloudWatchMetric(stat, value);
|
||||
}
|
||||
dd.dogstatsd.histogram(stat, value, tags);
|
||||
};
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function fetchLLMCompletion(
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIBasePath: baseURL,
|
||||
azureOpenAIApiDeploymentName: modelParams.model,
|
||||
azureOpenAIApiVersion: "2024-02-01",
|
||||
azureOpenAIApiVersion: "2025-02-01-preview",
|
||||
temperature: modelParams.temperature,
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
@@ -237,6 +237,22 @@ export async function fetchLLMCompletion(
|
||||
maxRetries,
|
||||
apiKey,
|
||||
});
|
||||
} else if (modelParams.adapter === LLMAdapter.Atla) {
|
||||
// Atla models do not support:
|
||||
// - temperature
|
||||
// - max_tokens
|
||||
// - top_p
|
||||
chatModel = new ChatOpenAI({
|
||||
openAIApiKey: apiKey,
|
||||
modelName: modelParams.model,
|
||||
callbacks: finalCallbacks,
|
||||
maxRetries,
|
||||
configuration: {
|
||||
baseURL: baseURL,
|
||||
defaultHeaders: extraHeaders,
|
||||
},
|
||||
timeout: 1000 * 60, // 1 minute timeout
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const _exhaustiveCheck: never = modelParams.adapter;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ChatMessageWithId = ChatMessage & { id: string };
|
||||
export enum LLMAdapter {
|
||||
Anthropic = "anthropic",
|
||||
OpenAI = "openai",
|
||||
Atla = "atla",
|
||||
Azure = "azure",
|
||||
Bedrock = "bedrock",
|
||||
VertexAI = "google-vertex-ai",
|
||||
@@ -152,6 +153,8 @@ export const googleAIStudioModels = [
|
||||
"gemini-1.5-flash-8b",
|
||||
] as const;
|
||||
|
||||
export const atlaModels = ["atla-selene", "atla-selene-20250214"] as const;
|
||||
|
||||
export type AnthropicModel = (typeof anthropicModels)[number];
|
||||
export type VertexAIModel = (typeof vertexAIModels)[number];
|
||||
export const supportedModels = {
|
||||
@@ -161,6 +164,7 @@ export const supportedModels = {
|
||||
[LLMAdapter.GoogleAIStudio]: googleAIStudioModels,
|
||||
[LLMAdapter.Azure]: [],
|
||||
[LLMAdapter.Bedrock]: [],
|
||||
[LLMAdapter.Atla]: atlaModels,
|
||||
} as const;
|
||||
|
||||
export type LLMFunctionCall = {
|
||||
|
||||
@@ -18,3 +18,4 @@ export {
|
||||
type ClickhouseOperator,
|
||||
} from "./clickhouse-sql/clickhouse-filter";
|
||||
export { orderByToClickhouseSql } from "./clickhouse-sql/orderby-factory";
|
||||
export { createFilterFromFilterState } from "./clickhouse-sql/factory";
|
||||
|
||||
@@ -56,6 +56,9 @@ export const EvalExecutionEvent = z.object({
|
||||
export const PostHogIntegrationProcessingEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
});
|
||||
export const BlobStorageIntegrationProcessingEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
});
|
||||
export const ExperimentCreateEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
@@ -146,6 +149,9 @@ export type DataRetentionProcessingEventType = z.infer<
|
||||
export type BatchActionProcessingEventType = z.infer<
|
||||
typeof BatchActionProcessingEventSchema
|
||||
>;
|
||||
export type BlobStorageIntegrationProcessingEventType = z.infer<
|
||||
typeof BlobStorageIntegrationProcessingEventSchema
|
||||
>;
|
||||
|
||||
export enum QueueName {
|
||||
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
|
||||
@@ -160,6 +166,8 @@ export enum QueueName {
|
||||
ExperimentCreate = "experiment-create-queue",
|
||||
PostHogIntegrationQueue = "posthog-integration-queue",
|
||||
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
|
||||
BlobStorageIntegrationQueue = "blobstorage-integration-queue",
|
||||
BlobStorageIntegrationProcessingQueue = "blobstorage-integration-processing-queue",
|
||||
CoreDataS3ExportQueue = "core-data-s3-export-queue",
|
||||
MeteringDataPostgresExportQueue = "metering-data-postgres-export-queue",
|
||||
DataRetentionQueue = "data-retention-queue",
|
||||
@@ -182,6 +190,8 @@ export enum QueueJobs {
|
||||
ExperimentCreateJob = "experiment-create-job",
|
||||
PostHogIntegrationJob = "posthog-integration-job",
|
||||
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
|
||||
BlobStorageIntegrationJob = "blobstorage-integration-job",
|
||||
BlobStorageIntegrationProcessingJob = "blobstorage-integration-processing-job",
|
||||
CoreDataS3ExportJob = "core-data-s3-export-job",
|
||||
MeteringDataPostgresExportJob = "metering-data-postgres-export-job",
|
||||
DataRetentionJob = "data-retention-job",
|
||||
@@ -276,4 +286,10 @@ export type TQueueJobTypes = {
|
||||
payload: CreateEvalQueueEventType;
|
||||
name: QueueJobs.CreateEvalJob;
|
||||
};
|
||||
[QueueName.BlobStorageIntegrationProcessingQueue]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: BlobStorageIntegrationProcessingEventType;
|
||||
name: QueueJobs.BlobStorageIntegrationProcessingJob;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class BlobStorageIntegrationProcessingQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (BlobStorageIntegrationProcessingQueue.instance) {
|
||||
return BlobStorageIntegrationProcessingQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
BlobStorageIntegrationProcessingQueue.instance = newRedis
|
||||
? new Queue(QueueName.BlobStorageIntegrationProcessingQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
BlobStorageIntegrationProcessingQueue.instance?.on("error", (err) => {
|
||||
logger.error("BlobStorageIntegrationProcessingQueue error", err);
|
||||
});
|
||||
|
||||
return BlobStorageIntegrationProcessingQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QueueName, QueueJobs } from "../queues";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class BlobStorageIntegrationQueue {
|
||||
private static instance: Queue | null = null;
|
||||
|
||||
public static getInstance(): Queue | null {
|
||||
if (BlobStorageIntegrationQueue.instance) {
|
||||
return BlobStorageIntegrationQueue.instance;
|
||||
}
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
BlobStorageIntegrationQueue.instance = newRedis
|
||||
? new Queue(QueueName.BlobStorageIntegrationQueue, {
|
||||
connection: newRedis,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
BlobStorageIntegrationQueue.instance?.on("error", (err) => {
|
||||
logger.error("BlobStorageIntegrationQueue error", err);
|
||||
});
|
||||
|
||||
if (BlobStorageIntegrationQueue.instance) {
|
||||
logger.debug("Scheduling jobs for BlobStorageIntegrationQueue");
|
||||
BlobStorageIntegrationQueue.instance
|
||||
.add(
|
||||
QueueJobs.BlobStorageIntegrationJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "20 * * * *" }, // every hour at 20 minutes past
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error("Error adding BlobStorageIntegrationJob schedule", err);
|
||||
});
|
||||
}
|
||||
|
||||
return BlobStorageIntegrationQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { TraceDeleteQueue } from "./traceDelete";
|
||||
import { ProjectDeleteQueue } from "./projectDelete";
|
||||
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
|
||||
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
|
||||
import { BlobStorageIntegrationQueue } from "./blobStorageIntegrationQueue";
|
||||
import { BlobStorageIntegrationProcessingQueue } from "./blobStorageIntegrationProcessingQueue";
|
||||
import { CoreDataS3ExportQueue } from "./coreDataS3ExportQueue";
|
||||
import { MeteringDataPostgresExportQueue } from "./meteringDataPostgresExportQueue";
|
||||
import { DataRetentionQueue } from "./dataRetentionQueue";
|
||||
@@ -43,6 +45,10 @@ export function getQueue(queueName: QueueName): Queue | null {
|
||||
return PostHogIntegrationQueue.getInstance();
|
||||
case QueueName.PostHogIntegrationProcessingQueue:
|
||||
return PostHogIntegrationProcessingQueue.getInstance();
|
||||
case QueueName.BlobStorageIntegrationQueue:
|
||||
return BlobStorageIntegrationQueue.getInstance();
|
||||
case QueueName.BlobStorageIntegrationProcessingQueue:
|
||||
return BlobStorageIntegrationProcessingQueue.getInstance();
|
||||
case QueueName.IngestionSecondaryQueue:
|
||||
return SecondaryIngestionQueue.getInstance();
|
||||
case QueueName.CoreDataS3ExportQueue:
|
||||
|
||||
@@ -253,7 +253,7 @@ export const groupTracesByTime = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getObservationUsageByTime = async (
|
||||
export const getTotalObservationUsageByTimeByModel = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
) => {
|
||||
@@ -286,8 +286,8 @@ export const getObservationUsageByTime = async (
|
||||
const query = `
|
||||
SELECT
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
|
||||
sumMap(usage_details) as units,
|
||||
sumMap(cost_details) as cost,
|
||||
sumMap(usage_details)['total'] as units,
|
||||
sumMap(cost_details)['total'] as cost,
|
||||
provided_model_name
|
||||
FROM observations o FINAL
|
||||
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
@@ -301,8 +301,8 @@ export const getObservationUsageByTime = async (
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
start_time: string;
|
||||
units: Record<string, number>;
|
||||
cost: Record<string, number>;
|
||||
units: string;
|
||||
cost: string;
|
||||
provided_model_name: string;
|
||||
}>({
|
||||
query,
|
||||
@@ -324,23 +324,209 @@ export const getObservationUsageByTime = async (
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
start_time: parseClickhouseUTCDateTimeFormat(row.start_time),
|
||||
units: Object.fromEntries(
|
||||
Object.entries(row.units ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
Number(value),
|
||||
]),
|
||||
),
|
||||
cost: Object.fromEntries(
|
||||
Object.entries(row.cost ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
Number(value),
|
||||
]),
|
||||
),
|
||||
provided_model_name: row.provided_model_name,
|
||||
startTime: parseClickhouseUTCDateTimeFormat(row.start_time),
|
||||
units: Number(row.units),
|
||||
cost: Number(row.cost),
|
||||
model: row.provided_model_name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getObservationCostByTypeByTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
) => {
|
||||
const { envFilter, remainingFilters } =
|
||||
extractEnvironmentFilterFromFilters(filter);
|
||||
const environmentFilter = new FilterList(
|
||||
convertEnvFilterToClickhouseFilter(envFilter),
|
||||
).apply();
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
|
||||
);
|
||||
|
||||
const appliedFilter = chFilter.apply();
|
||||
|
||||
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
const timeFilter = tracesFilter
|
||||
? (chFilter.find(
|
||||
(f) =>
|
||||
f.clickhouseTable === "observations" &&
|
||||
f.field.includes("start_time") &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined)
|
||||
: undefined;
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"start_time",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
start_time,
|
||||
groupArray((cost_key, cost_sum)) AS costs
|
||||
FROM (
|
||||
SELECT
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
|
||||
cost_key,
|
||||
SUM(cost) AS cost_sum
|
||||
FROM
|
||||
observations o FINAL
|
||||
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
ARRAY JOIN
|
||||
mapKeys(cost_details) AS cost_key,
|
||||
mapValues(cost_details) AS cost
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${appliedFilter.query}
|
||||
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
|
||||
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
GROUP BY
|
||||
start_time,
|
||||
cost_key
|
||||
)
|
||||
GROUP BY
|
||||
start_time
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
start_time: string;
|
||||
costs: Array<[string, number | null]>;
|
||||
}>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
...appliedFilter.params,
|
||||
...environmentFilter.params,
|
||||
...orderByParams,
|
||||
...(timeFilter
|
||||
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "dashboard",
|
||||
type: "observationCostByTypeByTime",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const types = result.flatMap((row) => {
|
||||
return row.costs.map((cost) => cost[0]);
|
||||
});
|
||||
|
||||
const uniqueTypes = [...new Set(types)];
|
||||
|
||||
return result.flatMap((row) => {
|
||||
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
|
||||
return uniqueTypes.map((type) => ({
|
||||
intervalStart: intervalStart,
|
||||
key: type,
|
||||
sum: row.costs.find((cost) => cost[0] === type)?.[1]
|
||||
? Number(row.costs.find((cost) => cost[0] === type)?.[1])
|
||||
: 0,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
export const getObservationUsageByTypeByTime = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
) => {
|
||||
const { envFilter, remainingFilters } =
|
||||
extractEnvironmentFilterFromFilters(filter);
|
||||
const environmentFilter = new FilterList(
|
||||
convertEnvFilterToClickhouseFilter(envFilter),
|
||||
).apply();
|
||||
const chFilter = new FilterList(
|
||||
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
|
||||
);
|
||||
|
||||
const appliedFilter = chFilter.apply();
|
||||
|
||||
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
|
||||
const timeFilter = tracesFilter
|
||||
? (chFilter.find(
|
||||
(f) =>
|
||||
f.clickhouseTable === "observations" &&
|
||||
f.field.includes("start_time") &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
) as DateTimeFilter | undefined)
|
||||
: undefined;
|
||||
|
||||
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
|
||||
filter,
|
||||
"start_time",
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
start_time,
|
||||
groupArray((usage_key, usage_sum)) AS usages
|
||||
FROM (
|
||||
SELECT
|
||||
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")} ,
|
||||
usage_key,
|
||||
SUM(usage) AS usage_sum
|
||||
FROM
|
||||
observations o FINAL
|
||||
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
|
||||
ARRAY JOIN
|
||||
mapKeys(usage_details) AS usage_key,
|
||||
mapValues(usage_details) AS usage
|
||||
WHERE project_id = {projectId: String}
|
||||
AND ${appliedFilter.query}
|
||||
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
|
||||
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
GROUP BY
|
||||
start_time,
|
||||
usage_key
|
||||
)
|
||||
GROUP BY
|
||||
start_time
|
||||
${orderByQuery}
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
start_time: string;
|
||||
usages: Array<[string, number | null]>;
|
||||
}>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
...appliedFilter.params,
|
||||
...environmentFilter.params,
|
||||
...orderByParams,
|
||||
...(timeFilter
|
||||
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "dashboard",
|
||||
type: "observationUsageByTime",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const types = result.flatMap((row) => {
|
||||
return row.usages.map((usage) => usage[0]);
|
||||
});
|
||||
|
||||
const uniqueTypes = [...new Set(types)];
|
||||
|
||||
return result.flatMap((row) => {
|
||||
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
|
||||
return uniqueTypes.map((type) => ({
|
||||
intervalStart: intervalStart,
|
||||
key: type,
|
||||
sum: row.usages.find((usage) => usage[0] === type)?.[1]
|
||||
? Number(row.usages.find((usage) => usage[0] === type)?.[1])
|
||||
: 0,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
export const getDistinctModels = async (
|
||||
projectId: string,
|
||||
filter: FilterState,
|
||||
|
||||
@@ -1324,6 +1324,59 @@ export const getTraceIdsForObservations = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getObservationsForBlobStorageExport = function (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
trace_id,
|
||||
project_id,
|
||||
environment,
|
||||
type,
|
||||
parent_observation_id,
|
||||
start_time,
|
||||
end_time,
|
||||
name,
|
||||
metadata,
|
||||
level,
|
||||
status_message,
|
||||
version,
|
||||
input,
|
||||
output,
|
||||
provided_model_name,
|
||||
model_parameters,
|
||||
usage_details,
|
||||
cost_details,
|
||||
completion_start_time,
|
||||
prompt_name,
|
||||
prompt_version
|
||||
FROM observations FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time >= {minTimestamp: DateTime64(3)}
|
||||
AND start_time <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
|
||||
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
|
||||
},
|
||||
tags: {
|
||||
feature: "blobstorage",
|
||||
type: "observation",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
export const getGenerationsForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
|
||||
@@ -846,6 +846,49 @@ export const getDistinctScoreNames = async (
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
export const getScoresForBlobStorageExport = function (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
project_id,
|
||||
environment,
|
||||
trace_id,
|
||||
observation_id,
|
||||
name,
|
||||
value,
|
||||
source,
|
||||
comment,
|
||||
data_type,
|
||||
string_value
|
||||
FROM scores FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp >= {minTimestamp: DateTime64(3)}
|
||||
AND timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
|
||||
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
|
||||
},
|
||||
tags: {
|
||||
feature: "blobstorage",
|
||||
type: "score",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
export const getScoresForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
|
||||
@@ -810,6 +810,52 @@ export const getUserMetrics = async (
|
||||
}));
|
||||
};
|
||||
|
||||
export const getTracesForBlobStorageExport = function (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
maxTimestamp: Date,
|
||||
) {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
name,
|
||||
environment,
|
||||
project_id,
|
||||
metadata,
|
||||
user_id,
|
||||
session_id,
|
||||
release,
|
||||
version,
|
||||
public,
|
||||
bookmarked,
|
||||
tags,
|
||||
input,
|
||||
output
|
||||
FROM traces FINAL
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp >= {minTimestamp: DateTime64(3)}
|
||||
AND timestamp <= {maxTimestamp: DateTime64(3)}
|
||||
`;
|
||||
|
||||
const records = queryClickhouseStream<Record<string, unknown>>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
|
||||
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
|
||||
},
|
||||
tags: {
|
||||
feature: "blobstorage",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
export const getTracesForPostHog = async function* (
|
||||
projectId: string,
|
||||
minTimestamp: Date,
|
||||
|
||||
@@ -260,136 +260,144 @@ export class PromptService {
|
||||
parentPrompt: PartialPrompt;
|
||||
dependencies?: ParsedPromptDependencyTag[];
|
||||
}): Promise<ResolvedPromptGraph> {
|
||||
const { projectId, parentPrompt, dependencies } = params;
|
||||
try {
|
||||
const { projectId, parentPrompt, dependencies } = params;
|
||||
|
||||
const graph: PromptGraph = {
|
||||
root: {
|
||||
name: parentPrompt.name,
|
||||
version: parentPrompt.version,
|
||||
id: parentPrompt.id,
|
||||
},
|
||||
dependencies: {},
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const graph: PromptGraph = {
|
||||
root: {
|
||||
name: parentPrompt.name,
|
||||
version: parentPrompt.version,
|
||||
id: parentPrompt.id,
|
||||
},
|
||||
dependencies: {},
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
|
||||
const resolve = async (
|
||||
currentPrompt: PartialPrompt,
|
||||
deps: ParsedPromptDependencyTag[] | undefined,
|
||||
level: number,
|
||||
) => {
|
||||
// Nesting depth check
|
||||
if (level >= MAX_PROMPT_NESTING_DEPTH) {
|
||||
throw Error(
|
||||
`Maximum nesting depth exceeded (${MAX_PROMPT_NESTING_DEPTH})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Circular dependency check
|
||||
if (
|
||||
seen.has(currentPrompt.id) ||
|
||||
(currentPrompt.name === parentPrompt.name &&
|
||||
currentPrompt.id !== parentPrompt.id) // ensure that the parent prompt cannot reference a prompt of the same name but different version
|
||||
) {
|
||||
throw Error(
|
||||
`Circular dependency detected involving prompt '${currentPrompt.name}' version ${currentPrompt.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
seen.add(currentPrompt.id);
|
||||
|
||||
// deps can be either passed (if a prompt is created and content was scanned) or retrieved from db
|
||||
let promptDependencies = deps;
|
||||
if (!deps) {
|
||||
promptDependencies = (
|
||||
await this.prisma.promptDependency.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
parentId: currentPrompt.id,
|
||||
},
|
||||
select: {
|
||||
childName: true,
|
||||
childLabel: true,
|
||||
childVersion: true,
|
||||
},
|
||||
})
|
||||
).map(
|
||||
(dep) =>
|
||||
({
|
||||
name: dep.childName,
|
||||
...(dep.childVersion
|
||||
? { type: "version", version: dep.childVersion }
|
||||
: { type: "label", label: dep.childLabel }),
|
||||
}) as ParsedPromptDependencyTag,
|
||||
);
|
||||
}
|
||||
|
||||
if (promptDependencies && promptDependencies.length) {
|
||||
// Instantiate resolved prompt, use stringfied version for regex operations
|
||||
// Do this inside if clause to skip stringify/parse overhead for prompts without dependencies
|
||||
let resolvedPrompt = JSON.stringify(currentPrompt.prompt);
|
||||
|
||||
for (const dep of promptDependencies) {
|
||||
const depPrompt = await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: dep.name,
|
||||
...(dep.type === "version"
|
||||
? { version: dep.version }
|
||||
: { labels: { has: dep.label } }),
|
||||
},
|
||||
});
|
||||
|
||||
const logName = `${dep.name} - ${dep.type} ${dep.type === "version" ? dep.version : dep.label}`;
|
||||
|
||||
if (!depPrompt)
|
||||
throw Error(`Prompt dependency not found: ${logName}`);
|
||||
if (depPrompt.type !== "text")
|
||||
throw Error(`Prompt dependency is not a text prompt: ${logName}`);
|
||||
|
||||
// side-effect: populate adjacency list to return later as well
|
||||
graph.dependencies[currentPrompt.id] ??= []; // initializes an empty list if it does not exist yet
|
||||
graph.dependencies[currentPrompt.id].push({
|
||||
id: depPrompt.id,
|
||||
name: depPrompt.name,
|
||||
version: depPrompt.version,
|
||||
});
|
||||
|
||||
// resolve the prompt content recursively
|
||||
const resolvedDepPrompt = await resolve(
|
||||
depPrompt,
|
||||
undefined,
|
||||
level + 1,
|
||||
const resolve = async (
|
||||
currentPrompt: PartialPrompt,
|
||||
deps: ParsedPromptDependencyTag[] | undefined,
|
||||
level: number,
|
||||
) => {
|
||||
// Nesting depth check
|
||||
if (level >= MAX_PROMPT_NESTING_DEPTH) {
|
||||
throw Error(
|
||||
`Maximum nesting depth exceeded (${MAX_PROMPT_NESTING_DEPTH})`,
|
||||
);
|
||||
|
||||
const versionPattern = `@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|version=${escapeRegex(depPrompt.version)}@@@`;
|
||||
const labelPatterns = depPrompt.labels.map(
|
||||
(label) =>
|
||||
`@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|label=${escapeRegex(label)}@@@`,
|
||||
);
|
||||
const combinedPattern = [versionPattern, ...labelPatterns].join("|");
|
||||
const regex = new RegExp(combinedPattern, "g");
|
||||
|
||||
const replaceValue = JSON.stringify(resolvedDepPrompt).slice(1, -1); // this is necessary to avoid parsing errors as resolved value is unstringified
|
||||
|
||||
resolvedPrompt = resolvedPrompt.replace(regex, replaceValue);
|
||||
}
|
||||
|
||||
seen.delete(currentPrompt.id);
|
||||
// Circular dependency check
|
||||
if (
|
||||
seen.has(currentPrompt.id) ||
|
||||
(currentPrompt.name === parentPrompt.name &&
|
||||
currentPrompt.id !== parentPrompt.id) // ensure that the parent prompt cannot reference a prompt of the same name but different version
|
||||
) {
|
||||
throw Error(
|
||||
`Circular dependency detected involving prompt '${currentPrompt.name}' version ${currentPrompt.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(resolvedPrompt);
|
||||
} else {
|
||||
seen.delete(currentPrompt.id);
|
||||
seen.add(currentPrompt.id);
|
||||
|
||||
return currentPrompt.prompt;
|
||||
}
|
||||
};
|
||||
// deps can be either passed (if a prompt is created and content was scanned) or retrieved from db
|
||||
let promptDependencies = deps;
|
||||
if (!deps) {
|
||||
promptDependencies = (
|
||||
await this.prisma.promptDependency.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
parentId: currentPrompt.id,
|
||||
},
|
||||
select: {
|
||||
childName: true,
|
||||
childLabel: true,
|
||||
childVersion: true,
|
||||
},
|
||||
})
|
||||
).map(
|
||||
(dep) =>
|
||||
({
|
||||
name: dep.childName,
|
||||
...(dep.childVersion
|
||||
? { type: "version", version: dep.childVersion }
|
||||
: { type: "label", label: dep.childLabel }),
|
||||
}) as ParsedPromptDependencyTag,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPrompt = await resolve(parentPrompt, dependencies, 0);
|
||||
if (promptDependencies && promptDependencies.length) {
|
||||
// Instantiate resolved prompt, use stringfied version for regex operations
|
||||
// Do this inside if clause to skip stringify/parse overhead for prompts without dependencies
|
||||
let resolvedPrompt = JSON.stringify(currentPrompt.prompt);
|
||||
|
||||
return {
|
||||
graph: Object.keys(graph.dependencies).length > 0 ? graph : null,
|
||||
resolvedPrompt,
|
||||
};
|
||||
for (const dep of promptDependencies) {
|
||||
const depPrompt = await this.prisma.prompt.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
name: dep.name,
|
||||
...(dep.type === "version"
|
||||
? { version: dep.version }
|
||||
: { labels: { has: dep.label } }),
|
||||
},
|
||||
});
|
||||
|
||||
const logName = `${dep.name} - ${dep.type} ${dep.type === "version" ? dep.version : dep.label}`;
|
||||
|
||||
if (!depPrompt)
|
||||
throw Error(`Prompt dependency not found: ${logName}`);
|
||||
if (depPrompt.type !== "text")
|
||||
throw Error(`Prompt dependency is not a text prompt: ${logName}`);
|
||||
|
||||
// side-effect: populate adjacency list to return later as well
|
||||
graph.dependencies[currentPrompt.id] ??= []; // initializes an empty list if it does not exist yet
|
||||
graph.dependencies[currentPrompt.id].push({
|
||||
id: depPrompt.id,
|
||||
name: depPrompt.name,
|
||||
version: depPrompt.version,
|
||||
});
|
||||
|
||||
// resolve the prompt content recursively
|
||||
const resolvedDepPrompt = await resolve(
|
||||
depPrompt,
|
||||
undefined,
|
||||
level + 1,
|
||||
);
|
||||
|
||||
const versionPattern = `@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|version=${escapeRegex(depPrompt.version)}@@@`;
|
||||
const labelPatterns = depPrompt.labels.map(
|
||||
(label) =>
|
||||
`@@@langfusePrompt:name=${escapeRegex(depPrompt.name)}\\|label=${escapeRegex(label)}@@@`,
|
||||
);
|
||||
const combinedPattern = [versionPattern, ...labelPatterns].join(
|
||||
"|",
|
||||
);
|
||||
const regex = new RegExp(combinedPattern, "g");
|
||||
|
||||
const replaceValue = JSON.stringify(resolvedDepPrompt).slice(1, -1); // this is necessary to avoid parsing errors as resolved value is unstringified
|
||||
|
||||
resolvedPrompt = resolvedPrompt.replace(regex, replaceValue);
|
||||
}
|
||||
|
||||
seen.delete(currentPrompt.id);
|
||||
|
||||
return JSON.parse(resolvedPrompt);
|
||||
} else {
|
||||
seen.delete(currentPrompt.id);
|
||||
|
||||
return currentPrompt.prompt;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedPrompt = await resolve(parentPrompt, dependencies, 0);
|
||||
|
||||
return {
|
||||
graph: Object.keys(graph.dependencies).length > 0 ? graph : null,
|
||||
resolvedPrompt,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private logError(message: string, ...args: any[]) {
|
||||
|
||||
@@ -69,8 +69,9 @@ export class StorageServiceFactory {
|
||||
externalEndpoint?: string | undefined;
|
||||
region: string | undefined;
|
||||
forcePathStyle: boolean;
|
||||
useAzureBlob?: boolean;
|
||||
}): StorageService {
|
||||
if (env.LANGFUSE_USE_AZURE_BLOB === "true") {
|
||||
if (params.useAzureBlob || env.LANGFUSE_USE_AZURE_BLOB === "true") {
|
||||
return new AzureBlobStorageService(params);
|
||||
}
|
||||
return new S3StorageService(params);
|
||||
@@ -133,10 +134,13 @@ class AzureBlobStorageService implements StorageService {
|
||||
if (typeof data === "string") {
|
||||
await blockBlobClient.upload(data, data.length);
|
||||
} else if (data instanceof Readable) {
|
||||
let offset = 0;
|
||||
const blockIds = [];
|
||||
for await (const chunk of data) {
|
||||
const blockId = Buffer.from(`block-${offset}`).toString("base64");
|
||||
// Azure requires block IDs to be base64 strings of the same length
|
||||
// Use a fixed format with padded index to ensure consistent length
|
||||
const blockIdStr: string = `block-${blockIds.length.toString().padStart(10, "0")}`;
|
||||
const blockId = Buffer.from(blockIdStr).toString("base64");
|
||||
|
||||
const bufferChunk = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk);
|
||||
@@ -147,11 +151,10 @@ class AzureBlobStorageService implements StorageService {
|
||||
bufferChunk.length,
|
||||
);
|
||||
blockIds.push(blockId);
|
||||
|
||||
offset += bufferChunk.length;
|
||||
}
|
||||
|
||||
await blockBlobClient.commitBlockList(blockIds);
|
||||
if (blockIds.length > 0) {
|
||||
await blockBlobClient.commitBlockList(blockIds);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unsupported data type. Must be Readable or string.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { evalDatasetFormFilterCols } from "../../tableDefinitions/tracesTable";
|
||||
import { FilterState } from "../../types";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
|
||||
import { Prisma, prisma } from "../../db";
|
||||
|
||||
type FetchDatasetItemsTableProps = {
|
||||
select: "count";
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
};
|
||||
|
||||
const getDatasetRunItemsTableGeneric = async <T>(
|
||||
props: FetchDatasetItemsTableProps,
|
||||
) => {
|
||||
const { select, projectId, filter } = props;
|
||||
|
||||
let sqlSelect: Prisma.Sql;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = Prisma.sql`count(*) as count`;
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line no-case-declarations, no-unused-vars
|
||||
const exhaustiveCheckDefault: never = select;
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
const datasetItemsFilter = tableColumnsToSqlFilterAndPrefix(
|
||||
filter,
|
||||
evalDatasetFormFilterCols,
|
||||
"dataset_items",
|
||||
);
|
||||
|
||||
const query = Prisma.sql`
|
||||
SELECT
|
||||
${sqlSelect}
|
||||
FROM dataset_run_items as dri
|
||||
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${projectId}
|
||||
WHERE dri.project_id = ${projectId}
|
||||
${datasetItemsFilter}
|
||||
`;
|
||||
|
||||
const res = await prisma.$queryRaw<T>(query);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
}) => {
|
||||
const res = await getDatasetRunItemsTableGeneric<Array<{ count: bigint }>>({
|
||||
select: "count",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
});
|
||||
|
||||
const totalCount = res.length > 0 ? Number(res[0].count) : 0;
|
||||
|
||||
return { totalCount };
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UiColumnMappings } from "./types";
|
||||
|
||||
// Make sure to update web/src/features/query/dashboardUiTableToViewMapping.ts if you make changes
|
||||
|
||||
export const dashboardColumnDefinitions: UiColumnMappings = [
|
||||
{
|
||||
uiTableName: "Trace Name",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { JsonNested } from "./zod";
|
||||
import { parse, isSafeNumber, isNumber } from "lossless-json";
|
||||
|
||||
/**
|
||||
* Deeply parses a JSON string or object for nested stringified JSON
|
||||
@@ -41,7 +42,18 @@ export const parseJsonPrioritised = (
|
||||
json: string,
|
||||
): JsonNested | string | undefined => {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
return parse(json, null, (value) => {
|
||||
if (isNumber(value)) {
|
||||
if (isSafeNumber(value)) {
|
||||
// Safe numbers (integers and decimals) can be converted to Number
|
||||
return Number(value.valueOf());
|
||||
} else {
|
||||
// For large integers beyond safe limits, preserve string representation
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}) as JsonNested;
|
||||
} catch (error) {
|
||||
return json;
|
||||
}
|
||||
|
||||
Generated
+302
-239
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20.18.3-alpine3.20 AS alpine
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
|
||||
|
||||
# It's important to update the index before installing packages to ensure you're getting the latest versions.
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14,12 +14,12 @@
|
||||
"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 --silent false --verbose false --runInBand --detectOpenHandles --selectProjects async-server",
|
||||
"test-sync": "dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects sync-server",
|
||||
"test-client": "dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects client",
|
||||
"test:watch": "dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects async-server",
|
||||
"test-sync": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects sync-server",
|
||||
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects client",
|
||||
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --watch --runInBand",
|
||||
"test:e2e": "dotenv -e ../.env -- playwright test",
|
||||
"test:e2e:server": "dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
|
||||
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
@@ -122,7 +122,7 @@
|
||||
"langchain": "^0.3.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^14.2.21",
|
||||
"next": "^14.2.25",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-query-params": "^5.0.1",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -181,6 +181,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
|
||||
@@ -4995,11 +4995,13 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
nullable: true
|
||||
nullable: true
|
||||
completion_tokens_details:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
nullable: true
|
||||
nullable: true
|
||||
required:
|
||||
- prompt_tokens
|
||||
@@ -5020,11 +5022,13 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
nullable: true
|
||||
nullable: true
|
||||
output_tokens_details:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
nullable: true
|
||||
nullable: true
|
||||
required:
|
||||
- input_tokens
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
getDatasetRunItemsTableCount,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
|
||||
const generateFilter = (datasetIds: string[]): FilterState => {
|
||||
return [
|
||||
{
|
||||
column: "Dataset",
|
||||
operator: "any of",
|
||||
type: "stringOptions",
|
||||
value: datasetIds,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
describe("trpc.datasets", () => {
|
||||
let projectId: string;
|
||||
let datasetIds: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
const { projectId: newProjectId } = await createOrgProjectAndApiKey();
|
||||
const datasetItemIds = [uuidv4(), uuidv4()];
|
||||
const datasetRunIds = [uuidv4(), uuidv4()];
|
||||
projectId = newProjectId;
|
||||
datasetIds = [uuidv4(), uuidv4()];
|
||||
|
||||
await prisma.dataset.createMany({
|
||||
data: datasetIds.map((datasetId, index) => ({
|
||||
id: datasetId,
|
||||
projectId: projectId,
|
||||
name: `test-${index}`,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.datasetItem.createMany({
|
||||
data: datasetIds.map((datasetId, index) => ({
|
||||
id: datasetItemIds[index],
|
||||
projectId: projectId,
|
||||
datasetId: datasetId,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.datasetRuns.createMany({
|
||||
data: datasetRunIds.map((datasetRunId, index) => ({
|
||||
id: datasetRunId,
|
||||
projectId: projectId,
|
||||
datasetId: datasetIds[index],
|
||||
name: `test-${index}`,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.datasetRunItems.createMany({
|
||||
data: datasetItemIds.map((datasetItemId, index) => ({
|
||||
id: uuidv4(),
|
||||
projectId: projectId,
|
||||
datasetItemId: datasetItemId,
|
||||
traceId: uuidv4(),
|
||||
datasetRunId: datasetRunIds[index],
|
||||
})),
|
||||
});
|
||||
});
|
||||
describe("GET datasetItems.countAll", () => {
|
||||
it("should GET all dataset run items with no filter", async () => {
|
||||
const { totalCount } = await getDatasetRunItemsTableCount({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
});
|
||||
|
||||
expect(totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should GET all dataset run items with filter", async () => {
|
||||
const { totalCount } = await getDatasetRunItemsTableCount({
|
||||
projectId: projectId,
|
||||
filter: generateFilter([datasetIds[0]]),
|
||||
});
|
||||
|
||||
expect(totalCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -497,6 +497,88 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["string", { testId: "this is a string metadata" }],
|
||||
["big-number", { testId: "1983516295378495150" }],
|
||||
["small-number", { testId: 5 }],
|
||||
["float-number", { testId: 5.5 }],
|
||||
])(
|
||||
"#6123: should treat %s metadata for traces as such",
|
||||
async (_type, metadataValue) => {
|
||||
const traceId = randomUUID();
|
||||
|
||||
const entity = {
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: metadataValue,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [entity],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById(traceId, projectId);
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(traceId);
|
||||
expect(JSON.stringify(trace!.metadata)).toBe(
|
||||
JSON.stringify(metadataValue),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["string", { testId: "this is a string metadata" }],
|
||||
["big-number", { testId: "1983516295378495150" }],
|
||||
["small-number", { testId: 5 }],
|
||||
["float-number", { testId: 5.5 }],
|
||||
])(
|
||||
"#6123: should treat %s metadata for observations as such",
|
||||
async (_type, metadataValue) => {
|
||||
const observationId = randomUUID();
|
||||
const traceId = randomUUID();
|
||||
|
||||
const entity = {
|
||||
id: randomUUID(),
|
||||
type: "span-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: observationId,
|
||||
traceId: traceId,
|
||||
startTime: new Date().toISOString(),
|
||||
metadata: metadataValue,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [entity],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const observation = await getObservationById(
|
||||
observationId,
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation!.id).toBe(observationId);
|
||||
expect(JSON.stringify(observation!.metadata)).toBe(
|
||||
JSON.stringify(metadataValue),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("#4900: should clear score comment on update with `null`", async () => {
|
||||
const scoreId = randomUUID();
|
||||
const score1 = {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { prepareUsageDataForTimeseriesChart } from "@/src/features/dashboard/components/ModelUsageChart";
|
||||
import { orderByTimeSeries } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
orderByTimeSeries,
|
||||
getObservationUsageByTypeByTime,
|
||||
createOrgProjectAndApiKey,
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
createObservationsCh,
|
||||
createObservation,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
describe("orderByTimeSeries", () => {
|
||||
it("should return correct bucket size and query for 1 hour time range", () => {
|
||||
@@ -89,55 +96,102 @@ describe("orderByTimeSeries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("aggregate time series for model cost and usage", () => {
|
||||
it("should aggregate time series for model cost and usage", async () => {
|
||||
const metricHistory = prepareUsageDataForTimeseriesChart(
|
||||
["gpt-4o-mini", "text-embedding-ada-002"],
|
||||
[
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: {
|
||||
input: 422,
|
||||
output: 61,
|
||||
total: 483,
|
||||
},
|
||||
cost: {
|
||||
input: 0.0000633,
|
||||
output: 0.0000366,
|
||||
total: 0.0000999,
|
||||
},
|
||||
model: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: {
|
||||
input: 6,
|
||||
total: 6,
|
||||
},
|
||||
cost: {
|
||||
total: 6e-7,
|
||||
},
|
||||
model: "text-embedding-ada-002",
|
||||
},
|
||||
],
|
||||
describe("getObservationUsageByTypeByTime", () => {
|
||||
const mockFilter = [
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: ">=" as const,
|
||||
value: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
type: "datetime" as const,
|
||||
column: "timestamp",
|
||||
operator: "<=" as const,
|
||||
value: new Date("2024-01-02T01:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
it("should return usage data grouped by time and type", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
|
||||
const trace = createTrace({
|
||||
name: "trace-name",
|
||||
project_id: projectId,
|
||||
timestamp: new Date("2024-01-01T01:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
const trace2 = createTrace({
|
||||
name: "trace-name",
|
||||
project_id: projectId,
|
||||
timestamp: new Date("2024-01-01T04:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
await createTracesCh([trace, trace2]);
|
||||
|
||||
const obs1 = createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: trace.project_id,
|
||||
usage_details: { input: 1, output: 2, total: 3 },
|
||||
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
const obs2 = createObservation({
|
||||
trace_id: trace.id,
|
||||
project_id: trace.project_id,
|
||||
usage_details: { input: 4, output: 5, total: 9 },
|
||||
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
const obs3 = createObservation({
|
||||
trace_id: trace2.id,
|
||||
project_id: trace.project_id,
|
||||
usage_details: { input: 400, output: 500, total: 900 },
|
||||
start_time: new Date("2024-01-01T04:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
await createObservationsCh([obs1, obs2, obs3]);
|
||||
|
||||
const result = await getObservationUsageByTypeByTime(
|
||||
projectId,
|
||||
mockFilter,
|
||||
);
|
||||
|
||||
expect(metricHistory.get("total")).toEqual([
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: 483,
|
||||
cost: 0.0000999,
|
||||
model: "gpt-4o-mini",
|
||||
usageType: "total",
|
||||
},
|
||||
{
|
||||
startTime: "2025-02-10T13:30:00.000Z",
|
||||
units: 6,
|
||||
cost: 6e-7,
|
||||
model: "text-embedding-ada-002",
|
||||
usageType: "total",
|
||||
},
|
||||
]);
|
||||
// Verify the structure of the returned data
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T01:00:00Z"),
|
||||
key: "input",
|
||||
sum: 5,
|
||||
},
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T01:00:00Z"),
|
||||
key: "output",
|
||||
sum: 7,
|
||||
},
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T01:00:00Z"),
|
||||
key: "total",
|
||||
sum: 12,
|
||||
},
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T04:00:00Z"),
|
||||
key: "input",
|
||||
sum: 400,
|
||||
},
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T04:00:00Z"),
|
||||
key: "output",
|
||||
sum: 500,
|
||||
},
|
||||
{
|
||||
intervalStart: new Date("2024-01-01T04:00:00Z"),
|
||||
key: "total",
|
||||
sum: 900,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,903 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
getTotalTraces,
|
||||
getTracesGroupedByName,
|
||||
getObservationsCostGroupedByName,
|
||||
getScoreAggregate,
|
||||
groupTracesByTime,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
import { dashboardColumnDefinitions } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Test suite for testing the self-serve dashboards functionality
|
||||
* This tests that the new query builder produces the same results as the existing dashboard queries
|
||||
*/
|
||||
describe("selfServeDashboards", () => {
|
||||
// Single project ID for all tests
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Time references
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const twoHoursAgo = new Date(now.getTime() - 7200000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
|
||||
|
||||
// Time ranges for queries - converted to ClickHouse DateTime format (YYYY-MM-DD HH:MM:SS.SSS)
|
||||
const defaultFromTime = threeDaysAgo.toISOString();
|
||||
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
|
||||
|
||||
// Test data statistics for verification
|
||||
const stats = {
|
||||
totalTraces: 0,
|
||||
productionTraces: 0,
|
||||
developmentTraces: 0,
|
||||
stagingTraces: 0,
|
||||
recentProductionTraces: 0, // within the last hour
|
||||
traceCounts: {} as Record<string, number>, // counts by trace name
|
||||
environmentCounts: {} as Record<string, number>, // counts by environment
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a diverse set of traces with different characteristics
|
||||
const traces = [
|
||||
// Production environment - common names
|
||||
...Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "chat-completion",
|
||||
environment: "production",
|
||||
timestamp: now.getTime() - i * 10000, // Slightly different timestamps
|
||||
user_id: "user-A",
|
||||
}),
|
||||
),
|
||||
...Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "embeddings",
|
||||
environment: "production",
|
||||
timestamp: now.getTime() - i * 15000,
|
||||
user_id: "user-B",
|
||||
}),
|
||||
),
|
||||
|
||||
// Production environment - older traces
|
||||
...Array(2)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "chat-completion",
|
||||
environment: "production",
|
||||
timestamp: twoHoursAgo.getTime() - i * 10000,
|
||||
user_id: "user-C",
|
||||
}),
|
||||
),
|
||||
|
||||
// Development environment - recent
|
||||
...Array(4)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "chat-completion",
|
||||
environment: "development",
|
||||
timestamp: oneHourAgo.getTime() - i * 20000,
|
||||
user_id: "user-D",
|
||||
}),
|
||||
),
|
||||
...Array(2)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "summarize",
|
||||
environment: "development",
|
||||
timestamp: now.getTime() - i * 5000,
|
||||
user_id: "user-E",
|
||||
}),
|
||||
),
|
||||
|
||||
// Staging environment
|
||||
...Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "qa-bot",
|
||||
environment: "staging",
|
||||
timestamp: now.getTime() - i * 30000,
|
||||
user_id: "user-F",
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// Insert traces into ClickHouse
|
||||
await createTracesCh(traces);
|
||||
|
||||
// Create observations for some of these traces
|
||||
const observations = [];
|
||||
|
||||
// Add observations for chat-completion traces in production
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const traceId = traces[i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "gpt-4-turbo",
|
||||
type: "generation",
|
||||
environment: "production",
|
||||
start_time: now.getTime() - i * 10000,
|
||||
completion_start_time: now.getTime() - i * 10000 + 800, // 800ms time to first token
|
||||
end_time: now.getTime() - i * 10000 + 3000, // 3000ms total duration
|
||||
provided_model_name: "gpt-4-turbo",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add observations for embeddings traces
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const traceId = traces[5 + i].id; // embeddings traces start at index 5
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "text-embedding-ada-002",
|
||||
type: "generation",
|
||||
environment: "production",
|
||||
start_time: now.getTime() - i * 15000,
|
||||
completion_start_time: now.getTime() - i * 15000 + 200, // 200ms time to first token
|
||||
end_time: now.getTime() - i * 15000 + 500, // 500ms total duration
|
||||
provided_model_name: "text-embedding-ada-002",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add observations for development traces
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const traceId = traces[10 + i].id; // development traces start at index 10
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "claude-3-opus",
|
||||
type: "generation",
|
||||
environment: "development",
|
||||
start_time: oneHourAgo.getTime() - i * 20000,
|
||||
completion_start_time: oneHourAgo.getTime() - i * 20000 + 1200, // 1200ms time to first token
|
||||
end_time: oneHourAgo.getTime() - i * 20000 + 4000, // 4000ms total duration
|
||||
provided_model_name: "claude-3-opus",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Insert observations into ClickHouse
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Calculate statistics for verification
|
||||
stats.totalTraces = traces.length;
|
||||
|
||||
// Count by environment
|
||||
traces.forEach((trace) => {
|
||||
stats.environmentCounts[trace.environment] =
|
||||
(stats.environmentCounts[trace.environment] || 0) + 1;
|
||||
});
|
||||
stats.productionTraces = stats.environmentCounts["production"] || 0;
|
||||
stats.developmentTraces = stats.environmentCounts["development"] || 0;
|
||||
stats.stagingTraces = stats.environmentCounts["staging"] || 0;
|
||||
|
||||
// Count traces by name
|
||||
traces.forEach((trace) => {
|
||||
stats.traceCounts[trace.name || ""] =
|
||||
(stats.traceCounts[trace.name || ""] || 0) + 1;
|
||||
});
|
||||
|
||||
// Count recent production traces (within the last hour)
|
||||
stats.recentProductionTraces = traces.filter(
|
||||
(t) =>
|
||||
t.environment === "production" && t.timestamp >= oneHourAgo.getTime(),
|
||||
).length;
|
||||
});
|
||||
|
||||
describe("traces-total query", () => {
|
||||
it("should return the same result with query builder as with legacy function", async () => {
|
||||
// Empty filter for this test
|
||||
const filter: FilterState = [
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date("1970-01-02"),
|
||||
},
|
||||
];
|
||||
|
||||
// 1. Get result using the legacy function
|
||||
const legacyResult = await getTotalTraces(projectId, filter);
|
||||
|
||||
// 2. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 3. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 4. Assert that both results match
|
||||
expect(queryBuilderResult).toHaveLength(1);
|
||||
expect(Number(queryBuilderResult[0].count_count)).toBe(stats.totalTraces);
|
||||
expect(Number(legacyResult?.[0]?.countTraceId)).toBe(stats.totalTraces);
|
||||
});
|
||||
|
||||
it("should filter traces by environment correctly", async () => {
|
||||
// 1. Define a filter for production environment in the legacy format
|
||||
const prodLegacyFilter: FilterState = [
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date("1970-01-02"),
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "production",
|
||||
},
|
||||
];
|
||||
const prodLegacyResult = await getTotalTraces(
|
||||
projectId,
|
||||
prodLegacyFilter,
|
||||
);
|
||||
|
||||
// 2. Define the equivalent query with filter for the query builder
|
||||
const prodQueryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
const prodQueryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
prodQueryBuilderQuery,
|
||||
);
|
||||
|
||||
// 3. Assert that both results match and only count production traces
|
||||
expect(prodQueryBuilderResult).toHaveLength(1);
|
||||
expect(Number(prodQueryBuilderResult[0].count_count)).toBe(
|
||||
stats.productionTraces,
|
||||
);
|
||||
expect(Number(prodLegacyResult?.[0]?.countTraceId)).toBe(
|
||||
stats.productionTraces,
|
||||
);
|
||||
|
||||
// 4. Test another filter for development environment
|
||||
const devLegacyFilter: FilterState = [
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date("1970-01-02"),
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "development",
|
||||
},
|
||||
];
|
||||
const devLegacyResult = await getTotalTraces(projectId, devLegacyFilter);
|
||||
|
||||
const devQueryBuilderQuery: QueryType = {
|
||||
...prodQueryBuilderQuery,
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "development",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
};
|
||||
const devQueryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
devQueryBuilderQuery,
|
||||
);
|
||||
|
||||
// 5. Assert development environment results
|
||||
expect(devQueryBuilderResult).toHaveLength(1);
|
||||
expect(Number(devQueryBuilderResult[0].count_count)).toBe(
|
||||
stats.developmentTraces,
|
||||
);
|
||||
expect(Number(devLegacyResult?.[0]?.countTraceId)).toBe(
|
||||
stats.developmentTraces,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple filter conditions", async () => {
|
||||
// 1. Define a filter for recent production traces in the legacy format
|
||||
const recentProdLegacyFilter: FilterState = [
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "production",
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: oneHourAgo,
|
||||
},
|
||||
];
|
||||
const legacyResult = await getTotalTraces(
|
||||
projectId,
|
||||
recentProdLegacyFilter,
|
||||
);
|
||||
|
||||
// 2. Define the equivalent query with multiple filters for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
column: "timestamp",
|
||||
operator: ">=",
|
||||
value: new Date(oneHourAgo),
|
||||
type: "datetime",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 3. Assert that both results match and only count recent production traces
|
||||
expect(queryBuilderResult).toHaveLength(1);
|
||||
expect(Number(queryBuilderResult[0].count_count)).toBe(
|
||||
stats.recentProductionTraces,
|
||||
);
|
||||
expect(legacyResult?.[0]?.countTraceId).toBe(
|
||||
`${stats.recentProductionTraces}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("traces-grouped-by-name query", () => {
|
||||
it("should return the same result with query builder as with legacy function", async () => {
|
||||
// 1. Get result using the legacy function
|
||||
const legacyResult = await getTracesGroupedByName(
|
||||
projectId,
|
||||
dashboardColumnDefinitions,
|
||||
[], // empty filter
|
||||
);
|
||||
|
||||
// 2. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 3. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 4. Verify both results
|
||||
const legacyResultMap = new Map(
|
||||
legacyResult.map((item) => [item.name, item.count]),
|
||||
);
|
||||
|
||||
// Verify results match the expected trace counts by name
|
||||
Object.keys(stats.traceCounts).forEach((traceName) => {
|
||||
const countFromLegacy = legacyResultMap.get(traceName);
|
||||
const resultRow = queryBuilderResult.find(
|
||||
(row: any) => row.name === traceName,
|
||||
);
|
||||
|
||||
expect(countFromLegacy).toBe(`${stats.traceCounts[traceName]}`);
|
||||
expect(Number(resultRow?.count_count)).toBe(
|
||||
stats.traceCounts[traceName],
|
||||
);
|
||||
});
|
||||
|
||||
// Verify both result sets have the same number of rows
|
||||
expect(legacyResult.length).toBe(Object.keys(stats.traceCounts).length);
|
||||
expect(queryBuilderResult.length).toBe(
|
||||
Object.keys(stats.traceCounts).length,
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter traces by environment when grouping by name", async () => {
|
||||
// 1. Define a filter for production environment
|
||||
const prodFilter: FilterState = [
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "production",
|
||||
},
|
||||
];
|
||||
|
||||
// 2. Get legacy result with filter
|
||||
const legacyResult = await getTracesGroupedByName(
|
||||
projectId,
|
||||
dashboardColumnDefinitions,
|
||||
prodFilter,
|
||||
);
|
||||
|
||||
// 3. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 4. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 5. Verify results
|
||||
// Get only traces with production environment
|
||||
const productionTraceNames = ["chat-completion", "embeddings"];
|
||||
|
||||
// Verify both results have the expected number of rows
|
||||
expect(legacyResult.length).toBe(productionTraceNames.length);
|
||||
expect(queryBuilderResult.length).toBe(productionTraceNames.length);
|
||||
|
||||
// Create easy-to-use maps for comparison
|
||||
const legacyResultMap = new Map(
|
||||
legacyResult.map((item) => [item.name, item.count]),
|
||||
);
|
||||
|
||||
// Check each production trace name is present with correct count
|
||||
productionTraceNames.forEach((traceName) => {
|
||||
const countFromLegacy = legacyResultMap.get(traceName);
|
||||
const resultRow = queryBuilderResult.find(
|
||||
(row: any) => row.name === traceName,
|
||||
);
|
||||
|
||||
expect(countFromLegacy).toBeDefined();
|
||||
expect(resultRow).toBeDefined();
|
||||
expect(resultRow?.count_count).toBe(countFromLegacy);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("observations-model-cost query", () => {
|
||||
it("should return the same result with query builder as with legacy function", async () => {
|
||||
// 1. Get result using the legacy function
|
||||
const legacyResult = await getObservationsCostGroupedByName(
|
||||
projectId,
|
||||
[], // empty filter
|
||||
);
|
||||
|
||||
// 2. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "totalCost", aggregation: "sum" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 3. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 4. Verify both results
|
||||
expect(queryBuilderResult).toBeDefined();
|
||||
expect(legacyResult).toBeDefined();
|
||||
|
||||
// Create maps for easier comparison
|
||||
const legacyResultMap = new Map(
|
||||
legacyResult.map((item) => [item.name, item]),
|
||||
);
|
||||
|
||||
// Verify each model's costs and token usage match
|
||||
queryBuilderResult.forEach((row: any) => {
|
||||
const modelName = row.provided_model_name;
|
||||
const legacyModelData = legacyResultMap.get(modelName);
|
||||
|
||||
expect(legacyModelData).toBeDefined();
|
||||
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
|
||||
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
|
||||
});
|
||||
|
||||
// Verify both result sets have the same number of models
|
||||
expect(legacyResult.length).toBe(queryBuilderResult.length);
|
||||
});
|
||||
|
||||
it("should filter observations by environment", async () => {
|
||||
// 1. Define a filter for production environment
|
||||
const prodFilter: FilterState = [
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "production",
|
||||
},
|
||||
];
|
||||
|
||||
// 2. Get legacy result with filter
|
||||
const legacyResult = await getObservationsCostGroupedByName(
|
||||
projectId,
|
||||
prodFilter,
|
||||
);
|
||||
|
||||
// 3. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "totalCost", aggregation: "sum" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 4. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 5. Verify results
|
||||
// Production models should only include gpt-4-turbo and text-embedding-ada-002
|
||||
const productionModels = ["gpt-4-turbo", "text-embedding-ada-002"];
|
||||
|
||||
// Verify both results have the expected number of rows
|
||||
expect(legacyResult.length).toBe(productionModels.length);
|
||||
expect(queryBuilderResult.length).toBe(productionModels.length);
|
||||
|
||||
// Create maps for easier comparison
|
||||
const legacyResultMap = new Map(
|
||||
legacyResult.map((item) => [item.name, item]),
|
||||
);
|
||||
|
||||
// Verify each production model is present with correct costs
|
||||
queryBuilderResult.forEach((row: any) => {
|
||||
const modelName = row.provided_model_name;
|
||||
const legacyModelData = legacyResultMap.get(modelName);
|
||||
|
||||
expect(productionModels).toContain(modelName);
|
||||
expect(legacyModelData).toBeDefined();
|
||||
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
|
||||
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("score-aggregate query", () => {
|
||||
it("should return the same result with query builder as with legacy function", async () => {
|
||||
// 1. Get result using the legacy function
|
||||
const legacyResult = await getScoreAggregate(projectId, [
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date("1970-01-02"),
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. Define the equivalent query for the query builder
|
||||
const queryBuilderNumericQuery: QueryType = {
|
||||
view: "scores-numeric",
|
||||
dimensions: [
|
||||
{ field: "name" },
|
||||
{ field: "source" },
|
||||
{ field: "dataType" },
|
||||
],
|
||||
metrics: [
|
||||
{ measure: "value", aggregation: "avg" },
|
||||
{ measure: "count", aggregation: "count" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 3. Get results using the query builder for numeric scores
|
||||
const queryBuilderNumericResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderNumericQuery,
|
||||
);
|
||||
|
||||
// 4. Check categorical scores separately (optional, as the test dataset may not include them)
|
||||
const queryCategoricalQuery: QueryType = {
|
||||
view: "scores-categorical",
|
||||
dimensions: [
|
||||
{ field: "name" },
|
||||
{ field: "source" },
|
||||
{ field: "dataType" },
|
||||
],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Get results for categorical scores
|
||||
const queryBuilderCatResult = await executeQuery(
|
||||
projectId,
|
||||
queryCategoricalQuery,
|
||||
);
|
||||
|
||||
// 5. Verify both results
|
||||
expect(queryBuilderNumericResult).toBeDefined();
|
||||
expect(legacyResult).toBeDefined();
|
||||
|
||||
// Check that all numeric scores from legacy query are present in new query
|
||||
// Note: This test assumes numeric scores. If you have categorical scores, you'd need to
|
||||
// handle them separately by checking against queryBuilderCatResult
|
||||
legacyResult.forEach((legacyScore) => {
|
||||
// Only check numeric scores here
|
||||
if (legacyScore.data_type === "numeric") {
|
||||
const matchingRow = queryBuilderNumericResult.find(
|
||||
(row: any) =>
|
||||
row.name === legacyScore.name &&
|
||||
row.source === legacyScore.source &&
|
||||
row.data_type === legacyScore.data_type,
|
||||
);
|
||||
|
||||
expect(matchingRow).toBeDefined();
|
||||
// Check count matches
|
||||
expect(Number(matchingRow?.count_count)).toBe(
|
||||
Number(legacyScore.count),
|
||||
);
|
||||
// Check average value is approximately the same
|
||||
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
|
||||
Number(legacyScore.avg_value),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// If we have categorical scores in our test data, verify those too
|
||||
const categoricalScores = legacyResult.filter(
|
||||
(score) => score.data_type === "categorical",
|
||||
);
|
||||
if (categoricalScores.length > 0 && queryBuilderCatResult.length > 0) {
|
||||
categoricalScores.forEach((legacyScore) => {
|
||||
const matchingRow = queryBuilderCatResult.find(
|
||||
(row: any) =>
|
||||
row.name === legacyScore.name &&
|
||||
row.source === legacyScore.source &&
|
||||
row.data_type === legacyScore.data_type,
|
||||
);
|
||||
|
||||
expect(matchingRow).toBeDefined();
|
||||
// Check count matches
|
||||
expect(Number(matchingRow?.count_count)).toBe(
|
||||
Number(legacyScore.count),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should filter scores by environment", async () => {
|
||||
// 1. Define a filter for production environment
|
||||
const prodFilter: FilterState = [
|
||||
{
|
||||
type: "string",
|
||||
operator: "=",
|
||||
column: "environment",
|
||||
value: "production",
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date("1970-01-02"),
|
||||
},
|
||||
];
|
||||
|
||||
// 2. Get legacy result with filter
|
||||
const legacyResult = await getScoreAggregate(projectId, prodFilter);
|
||||
|
||||
// 3. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "scores-numeric", // We'll just test numeric scores for simplicity
|
||||
dimensions: [
|
||||
{ field: "name" },
|
||||
{ field: "source" },
|
||||
{ field: "dataType" },
|
||||
],
|
||||
metrics: [
|
||||
{ measure: "value", aggregation: "avg" },
|
||||
{ measure: "count", aggregation: "count" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 4. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 5. Verify results
|
||||
// Production environment should only include certain scores (based on test data)
|
||||
// We can check if both results have the same number of scores for production
|
||||
expect(queryBuilderResult.length).toBe(
|
||||
legacyResult.filter((score) => score.data_type === "numeric").length,
|
||||
);
|
||||
|
||||
// Check that all numeric scores in production environment from legacy query match the new query
|
||||
legacyResult
|
||||
.filter((score) => score.data_type === "numeric")
|
||||
.forEach((legacyScore) => {
|
||||
const matchingRow = queryBuilderResult.find(
|
||||
(row: any) =>
|
||||
row.name === legacyScore.name &&
|
||||
row.source === legacyScore.source &&
|
||||
row.data_type === legacyScore.data_type,
|
||||
);
|
||||
|
||||
expect(matchingRow).toBeDefined();
|
||||
// Check count matches
|
||||
expect(Number(matchingRow?.count_count)).toBe(
|
||||
Number(legacyScore.count),
|
||||
);
|
||||
// Check average value is approximately the same
|
||||
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
|
||||
Number(legacyScore.avg_value),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("traces-timeseries query", () => {
|
||||
it("should return the same result with query builder as with legacy function", async () => {
|
||||
// 1. Get result using the legacy function
|
||||
const legacyResult = await groupTracesByTime(projectId, [
|
||||
{
|
||||
type: "datetime",
|
||||
operator: ">=",
|
||||
column: "timestamp",
|
||||
value: new Date(defaultFromTime),
|
||||
},
|
||||
{
|
||||
type: "datetime",
|
||||
operator: "<=",
|
||||
column: "timestamp",
|
||||
value: new Date(defaultToTime),
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. Define the equivalent query for the query builder
|
||||
const queryBuilderQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "hour",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// 3. Get result using the query builder
|
||||
const queryBuilderResult = await executeQuery(
|
||||
projectId,
|
||||
queryBuilderQuery,
|
||||
);
|
||||
|
||||
// 4. Verify both results
|
||||
// Both results should be between 70 and 80 rows
|
||||
expect(legacyResult.length).toBeGreaterThanOrEqual(70);
|
||||
expect(legacyResult.length).toBeLessThanOrEqual(80);
|
||||
expect(queryBuilderResult.length).toBeGreaterThanOrEqual(70);
|
||||
expect(queryBuilderResult.length).toBeLessThanOrEqual(80);
|
||||
|
||||
// Compare the non-zero results to each other. The legacy setup should have more empty records in the future
|
||||
// so the indexing should match up.
|
||||
legacyResult.forEach((result, index) => {
|
||||
if (result.countTraceId > 0) {
|
||||
const queryBuilderRow = queryBuilderResult[index];
|
||||
expect(queryBuilderRow).toBeDefined();
|
||||
expect(Number(queryBuilderRow.count_count)).toBe(result.countTraceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
|
||||
/**
|
||||
* Test suite for testing SQL injection vulnerabilities in the QueryBuilder
|
||||
*/
|
||||
describe("QueryBuilder SQL Injection Tests", () => {
|
||||
// Single project ID for all tests
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Time references
|
||||
const now = new Date();
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
|
||||
|
||||
// Time ranges for queries - converted to ClickHouse DateTime format
|
||||
const defaultFromTime = threeDaysAgo.toISOString();
|
||||
const defaultToTime = now.toISOString();
|
||||
|
||||
// Create a mock ClickHouse client for testing
|
||||
const mockClickhouseClient = {
|
||||
query: jest.fn().mockImplementation(({ query, query_params }) => {
|
||||
// Return the query and params for inspection in tests
|
||||
return Promise.resolve({
|
||||
json: jest.fn().mockReturnValue({
|
||||
data: [],
|
||||
query,
|
||||
params: query_params,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
// Helper function to build a query without executing it
|
||||
const buildQueryWithoutExecuting = (query: QueryType, projectId: string) => {
|
||||
const queryBuilder = new QueryBuilder(mockClickhouseClient as any);
|
||||
return queryBuilder.build(query, projectId);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("SQL Injection via View Parameter", () => {
|
||||
it("should prevent injection via invalid view name", async () => {
|
||||
// Comment: The view property is restricted to specific enum values,
|
||||
// but a determined attacker might try to bypass zod validation or
|
||||
// supply a maliciously crafted view name
|
||||
const maliciousQuery = {
|
||||
view: "traces; DROP TABLE users" as any,
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
};
|
||||
|
||||
// Should throw an error rather than allow the injection
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Dimension Fields", () => {
|
||||
it("should prevent injection via dimension field name", async () => {
|
||||
// Comment: The field names in dimensions should be validated against allowed fields
|
||||
// in the view declaration. This test checks if an attacker can inject arbitrary SQL
|
||||
// by manipulating the dimension field name.
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name; DROP TABLE traces; --" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should throw an error for invalid dimension
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid dimension");
|
||||
});
|
||||
|
||||
it("should safely handle special characters in valid dimension fields", async () => {
|
||||
// Comment: Even with valid fields, we need to ensure special characters
|
||||
// don't lead to injections when building the SQL query
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }], // Valid field
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "name",
|
||||
operator: "=",
|
||||
value: "chat'; DROP TABLE traces; --", // SQL injection in value
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should build a valid query using parameterized queries for safety
|
||||
const result = buildQueryWithoutExecuting(query, projectId);
|
||||
|
||||
// Ensure the value is parameterized and not directly included in the SQL
|
||||
expect(result.query).not.toContain("chat'; DROP TABLE traces; --");
|
||||
expect(Object.values(result.parameters)).toContain(
|
||||
"chat'; DROP TABLE traces; --",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Metrics", () => {
|
||||
it("should prevent injection via metric measure name", async () => {
|
||||
// Comment: Similar to dimensions, metrics should be validated against allowed measures
|
||||
// This test checks if an attacker can inject SQL via the measure property
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [
|
||||
{
|
||||
measure: "count); DROP TABLE traces; --",
|
||||
aggregation: "count",
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should throw an error for invalid metric
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid metric");
|
||||
});
|
||||
|
||||
it("should prevent injection via metric aggregation", async () => {
|
||||
// Comment: The aggregation function could be another injection vector if
|
||||
// not properly validated
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [
|
||||
{
|
||||
measure: "count",
|
||||
aggregation: "count); DROP TABLE traces; --" as any,
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should throw an error for invalid aggregation
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Filters", () => {
|
||||
it("should prevent injection via filter field name", async () => {
|
||||
// Comment: Filter field names must be validated like dimensions
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment); DROP TABLE traces; --",
|
||||
operator: "=",
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should throw an error for invalid filter field
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid filter");
|
||||
});
|
||||
|
||||
it("should prevent injection via filter operator", async () => {
|
||||
// Comment: Filter operators should be validated against a list of allowed operators
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=; DROP TABLE traces; --" as any,
|
||||
value: "production",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should throw an error for invalid operator
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
|
||||
it("should safely handle special characters in filter values", async () => {
|
||||
// Comment: Filter values should be parameterized to prevent injection
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "=",
|
||||
type: "string",
|
||||
value: "production'; DROP TABLE traces; --",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should build a valid query with parameterization
|
||||
const result = buildQueryWithoutExecuting(query, projectId);
|
||||
|
||||
// Check for parameterization
|
||||
expect(result.query).not.toContain("production'; DROP TABLE traces; --");
|
||||
expect(Object.values(result.parameters)).toContain(
|
||||
"production'; DROP TABLE traces; --",
|
||||
);
|
||||
});
|
||||
|
||||
it("should safely handle array values in IN operators", async () => {
|
||||
// Comment: IN operators with arrays need special handling for SQL injection prevention
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "environment",
|
||||
operator: "any of",
|
||||
value: ["production", "development'); DROP TABLE traces; --"],
|
||||
type: "stringOptions",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Should parameterize the values
|
||||
const result = buildQueryWithoutExecuting(query, projectId);
|
||||
|
||||
expect(result.query).not.toContain(
|
||||
"production,development'); DROP TABLE traces; --",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Time Dimension", () => {
|
||||
it("should prevent injection via time dimension granularity", async () => {
|
||||
// Comment: Time dimension granularity should be validated against allowed values
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "minute; DROP TABLE traces; --" as any,
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
// Should throw an error for invalid granularity
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Timestamp Parameters", () => {
|
||||
it("should safely handle malicious timestamp strings", async () => {
|
||||
// Comment: Timestamps need to be properly validated and converted
|
||||
// to prevent SQL injection
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: "2023-01-01'); DROP TABLE traces; --",
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
// Should throw an error for invalid timestamp format
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via Project ID", () => {
|
||||
it("should safely handle malicious project ID", async () => {
|
||||
// Comment: Project ID is a critical parameter that must be properly sanitized
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const maliciousProjectId = "fake-id'; DROP TABLE traces; --";
|
||||
|
||||
// Should parameterize the project ID
|
||||
const result = buildQueryWithoutExecuting(query, maliciousProjectId);
|
||||
|
||||
// Check for parameterization
|
||||
expect(result.query).not.toContain(maliciousProjectId);
|
||||
expect(Object.values(result.parameters)).toContain(maliciousProjectId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection via OrderBy Parameters", () => {
|
||||
it("should prevent injection via orderBy field name", async () => {
|
||||
// Comment: The field names in orderBy should be validated against dimension and metric fields
|
||||
// to prevent SQL injection via field name
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{
|
||||
field: "name; DROP TABLE traces; --",
|
||||
direction: "asc",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Should throw an error for invalid orderBy field
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid orderBy field");
|
||||
});
|
||||
|
||||
it("should prevent injection via orderBy direction", async () => {
|
||||
// Comment: The direction value should be validated to prevent SQL injection
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{
|
||||
field: "name",
|
||||
direction: "asc; DROP TABLE traces; --" as any,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Should throw an error for invalid direction
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid query");
|
||||
});
|
||||
|
||||
it("should prevent injection via non-existing metric field in orderBy", async () => {
|
||||
// Comment: The field must exist as a metric with proper aggregation prefix
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{
|
||||
field: "sum_malicious_metric; DROP TABLE traces; --",
|
||||
direction: "asc",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Should throw an error for invalid orderBy field
|
||||
expect(() =>
|
||||
buildQueryWithoutExecuting(maliciousQuery, projectId),
|
||||
).toThrow("Invalid orderBy field");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration with executeQuery function", () => {
|
||||
it("should safely handle malicious query parameters through executeQuery", async () => {
|
||||
// Comment: This tests the integration with the dashboard router's executeQuery function
|
||||
// to ensure SQL injection protection works end-to-end
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const maliciousQuery: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [
|
||||
{
|
||||
measure: "count); DELETE FROM traces; --" as any,
|
||||
aggregation: "count",
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Expect the executeQuery function to throw a TRPC error
|
||||
// rather than allowing the injection
|
||||
await expect(executeQuery(projectId, maliciousQuery)).rejects.toThrow(
|
||||
TRPCError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -730,6 +730,36 @@ describe("OTel Resource Span Mapping", () => {
|
||||
entityAttributeValue: "gpt-4o-mini",
|
||||
},
|
||||
],
|
||||
[
|
||||
"#6084: should map input to input for pydantic",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "input",
|
||||
otelAttributeValue: {
|
||||
stringValue: JSON.stringify({
|
||||
task: "Play some chess",
|
||||
stream: false,
|
||||
}),
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: JSON.stringify({
|
||||
task: "Play some chess",
|
||||
stream: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
"#6084: should map model_config to modelParameters",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "model_config",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"max_tokens": 4096}',
|
||||
},
|
||||
entityAttributeKey: "modelParameters.max_tokens",
|
||||
entityAttributeValue: 4096,
|
||||
},
|
||||
],
|
||||
[
|
||||
"#5412: should map input.value to input for smolagents",
|
||||
{
|
||||
@@ -796,6 +826,54 @@ describe("OTel Resource Span Mapping", () => {
|
||||
entityAttributeValue: '{"foo": "bar"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map langfuse.metadata string to top-level metadata for trace",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "langfuse.metadata",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"customer_id": "123", "experiment": "test-run-1"}',
|
||||
},
|
||||
entityAttributeKey: "metadata.customer_id",
|
||||
entityAttributeValue: "123",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should map langfuse.metadata string to top-level metadata for observation",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "langfuse.metadata",
|
||||
otelAttributeValue: {
|
||||
stringValue: '{"customer_id": "123", "experiment": "test-run-1"}',
|
||||
},
|
||||
entityAttributeKey: "metadata.customer_id",
|
||||
entityAttributeValue: "123",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract metadata from langfuse.metadata.* keys for trace",
|
||||
{
|
||||
entity: "trace",
|
||||
otelAttributeKey: "langfuse.metadata.user_type",
|
||||
otelAttributeValue: {
|
||||
stringValue: "premium",
|
||||
},
|
||||
entityAttributeKey: "metadata.user_type",
|
||||
entityAttributeValue: "premium",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract metadata from langfuse.metadata.* keys for observation",
|
||||
{
|
||||
entity: "observation",
|
||||
otelAttributeKey: "langfuse.metadata.user_type",
|
||||
otelAttributeValue: {
|
||||
stringValue: "premium",
|
||||
},
|
||||
entityAttributeKey: "metadata.user_type",
|
||||
entityAttributeValue: "premium",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"Attributes: %s",
|
||||
(
|
||||
@@ -882,6 +960,30 @@ describe("OTel Resource Span Mapping", () => {
|
||||
entityAttributeValue: "default",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract metadata from resource attributes",
|
||||
{
|
||||
entity: "observation",
|
||||
otelResourceAttributeKey: "langfuse.metadata",
|
||||
otelResourceAttributeValue: {
|
||||
stringValue: '{"resource_id": "xyz", "region": "us-west-2"}',
|
||||
},
|
||||
entityAttributeKey: "metadata.resource_id",
|
||||
entityAttributeValue: "xyz",
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract metadata from langfuse.metadata.* resource attributes",
|
||||
{
|
||||
entity: "observation",
|
||||
otelResourceAttributeKey: "langfuse.metadata.server_name",
|
||||
otelResourceAttributeValue: {
|
||||
stringValue: "web-server-01",
|
||||
},
|
||||
entityAttributeKey: "metadata.server_name",
|
||||
entityAttributeValue: "web-server-01",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"ResourceAttributes: %s",
|
||||
(
|
||||
@@ -917,9 +1019,11 @@ describe("OTel Resource Span Mapping", () => {
|
||||
// Then
|
||||
const entity: { body: Record<string, any> } =
|
||||
spec.entity === "trace" ? langfuseEvents[0] : langfuseEvents[1];
|
||||
expect(entity.body[spec.entityAttributeKey]).toEqual(
|
||||
spec.entityAttributeValue,
|
||||
);
|
||||
expect(
|
||||
spec.entityAttributeKey // This logic allows to follow a path in the object, e.g. foo.bar.baz.
|
||||
.split(".")
|
||||
.reduce((acc: any, key: string) => acc && acc[key], entity.body),
|
||||
).toEqual(spec.entityAttributeValue);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ describe("parseJsonPrioritised", () => {
|
||||
'{"array": [1, 2], "nested": {"key": "value"}}',
|
||||
{ array: [1, 2], nested: { key: "value" } },
|
||||
], // Complex object
|
||||
["1983516295378495150", "1983516295378495150"], // Large number
|
||||
["3.4", 3.4], // Decimal number
|
||||
])(
|
||||
"should parse input correctly (%s, %s)",
|
||||
(input: string, expectedOutput: any) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { Lock, AlertCircle, Sparkle } from "lucide-react";
|
||||
import { Button, type ButtonProps } from "@/src/components/ui/button";
|
||||
import {
|
||||
@@ -28,19 +29,25 @@ interface ActionButtonProps extends ButtonProps {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
loading = false,
|
||||
hasAccess = true,
|
||||
hasEntitlement = true,
|
||||
limitValue,
|
||||
limit = false,
|
||||
disabled = false,
|
||||
children,
|
||||
icon,
|
||||
className,
|
||||
href,
|
||||
...buttonProps
|
||||
}: ActionButtonProps) {
|
||||
export const ActionButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ActionButtonProps
|
||||
>(function ActionButton(
|
||||
{
|
||||
loading = false,
|
||||
hasAccess = true,
|
||||
hasEntitlement = true,
|
||||
limitValue,
|
||||
limit = false,
|
||||
disabled = false,
|
||||
children,
|
||||
icon,
|
||||
className,
|
||||
href,
|
||||
...buttonProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasReachedLimit =
|
||||
typeof limit === "number" &&
|
||||
limitValue !== undefined &&
|
||||
@@ -65,6 +72,7 @@ export function ActionButton({
|
||||
|
||||
const btnContent = (
|
||||
<ButtonContent
|
||||
ref={ref}
|
||||
icon={icon}
|
||||
isDisabled={isDisabled}
|
||||
loading={loading}
|
||||
@@ -95,31 +103,37 @@ export function ActionButton({
|
||||
}
|
||||
|
||||
return btnContent;
|
||||
}
|
||||
});
|
||||
|
||||
function ButtonContent({
|
||||
icon,
|
||||
isDisabled,
|
||||
loading,
|
||||
hasAccess,
|
||||
hasEntitlement,
|
||||
hasReachedLimit,
|
||||
className,
|
||||
buttonProps,
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
isDisabled: boolean;
|
||||
loading: boolean;
|
||||
hasAccess: boolean;
|
||||
hasEntitlement: boolean;
|
||||
hasReachedLimit: boolean;
|
||||
className?: string;
|
||||
buttonProps: Omit<ButtonProps, "disabled" | "loading" | "className">;
|
||||
children: React.ReactNode;
|
||||
href?: string;
|
||||
}) {
|
||||
const ButtonContent = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
icon?: React.ReactNode;
|
||||
isDisabled: boolean;
|
||||
loading: boolean;
|
||||
hasAccess: boolean;
|
||||
hasEntitlement: boolean;
|
||||
hasReachedLimit: boolean;
|
||||
className?: string;
|
||||
buttonProps: Omit<ButtonProps, "disabled" | "loading" | "className">;
|
||||
children: React.ReactNode;
|
||||
href?: string;
|
||||
}
|
||||
>(function ButtonContent(
|
||||
{
|
||||
icon,
|
||||
isDisabled,
|
||||
loading,
|
||||
hasAccess,
|
||||
hasEntitlement,
|
||||
hasReachedLimit,
|
||||
className,
|
||||
buttonProps,
|
||||
children,
|
||||
href,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const content = (
|
||||
<>
|
||||
{!hasAccess ? (
|
||||
@@ -139,6 +153,7 @@ function ButtonContent({
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
loading={loading}
|
||||
className={className}
|
||||
@@ -148,4 +163,4 @@ function ButtonContent({
|
||||
{renderLink ? <Link href={href}>{content}</Link> : content}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,12 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { ChevronDownIcon, Loader } from "lucide-react";
|
||||
import { Download, Loader } from "lucide-react";
|
||||
import {
|
||||
type BatchExportTableName,
|
||||
exportOptions,
|
||||
@@ -69,31 +72,29 @@ export const BatchExportTableButton: React.FC<BatchExportTableButtonProps> = (
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto whitespace-nowrap">
|
||||
<span className="hidden @6xl:inline">
|
||||
{props.filterState.length > 0 || props.searchQuery
|
||||
? "Export selection"
|
||||
: "Export all"}{" "}
|
||||
</span>
|
||||
<span className="@6xl:hidden">Export</span>
|
||||
<Button variant="outline" size="icon" title="Export">
|
||||
{isExporting ? (
|
||||
<Loader className="ml-2 h-4 w-4 animate-spin" />
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{Object.entries(exportOptions).map(([key, options]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
className="capitalize"
|
||||
onClick={() => void handleExport(key as BatchExportFileFormat)}
|
||||
>
|
||||
as {options.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Export</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(exportOptions).map(([key, options]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
className="capitalize"
|
||||
onClick={() => void handleExport(key as BatchExportFileFormat)}
|
||||
>
|
||||
as {options.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
modelParamsKey="model"
|
||||
disabled={formDisabled}
|
||||
value={modelParams.model.value}
|
||||
options={availableModels}
|
||||
options={[...new Set(availableModels)]}
|
||||
updateModelParam={updateModelParamValue}
|
||||
modelParamsDescription={modelParamsDescription}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
isTableDataRangeOptionAvailable,
|
||||
isDashboardDateRangeOptionAvailable,
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -45,8 +44,8 @@ const BaseDateRangeDropdown = <T extends string>({
|
||||
}: 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" />
|
||||
<SelectTrigger className="w-fit font-medium hover:bg-accent hover:text-accent-foreground focus:ring-0 focus:ring-offset-0">
|
||||
{selectedOption !== "All time" && <span>Past</span>}
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" defaultValue={60}>
|
||||
|
||||
@@ -62,8 +62,8 @@ const PageHeader = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="relative inline-block max-w-md md:max-w-none">
|
||||
<h2 className="inline text-lg font-semibold leading-7">
|
||||
<span className="break-words">
|
||||
<h2 className="line-clamp-1 text-lg font-semibold leading-7">
|
||||
<span className="break-words" title={title}>
|
||||
{title}
|
||||
{help && (
|
||||
<span className="whitespace-nowrap">
|
||||
|
||||
@@ -14,7 +14,7 @@ const Page = ({
|
||||
children,
|
||||
headerProps,
|
||||
scrollable = false,
|
||||
withPadding = true,
|
||||
withPadding = false,
|
||||
}: PageContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SupportMenuDropdown } from "@/src/components/nav/support-menu-dropdown"
|
||||
import { SidebarMenuButton } from "@/src/components/ui/sidebar";
|
||||
import { useCommandMenu } from "@/src/features/command-k-menu/CommandMenuProvider";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { CloudStatusMenu } from "@/src/features/cloud-status-notification/components/CloudStatusMenu";
|
||||
|
||||
export type Route = {
|
||||
title: string;
|
||||
@@ -151,6 +152,12 @@ export const ROUTES: Route[] = [
|
||||
organizationRbacScope: "langfuseCloudBilling:CRUD",
|
||||
show: ({ organization }) => organization?.plan === "cloud:hobby",
|
||||
},
|
||||
{
|
||||
title: "Cloud Status",
|
||||
bottom: true,
|
||||
pathname: "",
|
||||
menuNode: <CloudStatusMenu />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
pathname: "/project/[projectId]/settings",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
|
||||
export const SettingsTableCard = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card className="mb-4 flex max-h-[60dvh] flex-col overflow-hidden [&>:first-child>:first-child]:border-t-0">
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowUpRight,
|
||||
Bug,
|
||||
Github,
|
||||
Radio,
|
||||
LibraryBig,
|
||||
LifeBuoy,
|
||||
Lightbulb,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@/src/features/support-chat/chat";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { SiDiscord } from "react-icons/si";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
type SupportMenuItem = {
|
||||
title: string;
|
||||
@@ -105,6 +107,13 @@ export const SupportMenuDropdown = () => {
|
||||
icon: LibraryBig,
|
||||
customizableHref: "documentationHref",
|
||||
});
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
items.push({
|
||||
title: "Status Page",
|
||||
pathname: "https://status.langfuse.com",
|
||||
icon: Radio,
|
||||
});
|
||||
}
|
||||
items.push("separator");
|
||||
|
||||
if (uiCustomization?.feedbackHref) {
|
||||
|
||||
@@ -192,6 +192,7 @@ export const SessionPage: React.FC<{
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
scrollable
|
||||
headerProps={{
|
||||
title: sessionId,
|
||||
@@ -220,24 +221,26 @@ export const SessionPage: React.FC<{
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
actionButtonsRight: [
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={encodeURIComponent(sessionId)}
|
||||
path={(entry) =>
|
||||
`/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`
|
||||
}
|
||||
listKey="sessions"
|
||||
/>,
|
||||
<CommentDrawerButton
|
||||
key="comment"
|
||||
variant="outline"
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType="SESSION"
|
||||
count={sessionCommentCounts.data?.get(sessionId)}
|
||||
/>,
|
||||
],
|
||||
actionButtonsRight: (
|
||||
<>
|
||||
<DetailPageNav
|
||||
key="nav"
|
||||
currentId={encodeURIComponent(sessionId)}
|
||||
path={(entry) =>
|
||||
`/project/${projectId}/sessions/${encodeURIComponent(entry.id)}`
|
||||
}
|
||||
listKey="sessions"
|
||||
/>
|
||||
<CommentDrawerButton
|
||||
key="comment"
|
||||
variant="outline"
|
||||
projectId={projectId}
|
||||
objectId={sessionId}
|
||||
objectType="SESSION"
|
||||
count={sessionCommentCounts.data?.get(sessionId)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -25,9 +25,13 @@ export function StarToggle({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={() => void onClick(!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onClick(!value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
loading={isLoading}
|
||||
aria-label="bookmark"
|
||||
>
|
||||
<StarIcon
|
||||
className="h-4 w-4"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
|
||||
export const TableWithMetadataWrapper = ({
|
||||
tableComponent,
|
||||
cardTitleChildren,
|
||||
cardContentChildren,
|
||||
}: {
|
||||
tableComponent: ReactNode;
|
||||
cardTitleChildren: ReactNode;
|
||||
cardContentChildren: ReactNode;
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid flex-1 ${isCollapsed ? "grid-cols-[2fr,auto]" : "grid-cols-[2fr,1fr]"} gap-4 overflow-hidden`}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{tableComponent}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-row ${isCollapsed ? "w-8" : "w-full"} h-full overflow-hidden`}
|
||||
>
|
||||
<div className="grid h-full w-full grid-cols-[auto,1fr] items-start gap-2 overflow-hidden">
|
||||
<div className="grid h-full w-full grid-rows-[auto,1fr] gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transform ${isCollapsed ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="ml-4 h-full" />
|
||||
</div>
|
||||
<div
|
||||
className={`${isCollapsed ? "hidden" : "block"} grid max-h-full w-full grid-rows-[auto,1fr] overflow-hidden p-2`}
|
||||
>
|
||||
<div className="my-2 flex h-6 flex-wrap items-center gap-2 @container" />
|
||||
<Card className="flex h-full flex-col overflow-hidden">
|
||||
<CardHeader className="flex h-full w-full flex-col space-y-4">
|
||||
<CardTitle className="flex justify-between text-xl font-bold leading-7 sm:tracking-tight">
|
||||
{cardTitleChildren}
|
||||
</CardTitle>
|
||||
<CardContent className="flex-1 space-y-4 overflow-y-auto p-0">
|
||||
{cardContentChildren}
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns,
|
||||
Component,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
@@ -73,11 +72,12 @@ const calculateColumnCounts = <TData, TValue>(
|
||||
);
|
||||
acc.count += groupCounts.count;
|
||||
acc.total += groupCounts.total;
|
||||
} else if (column.enableHiding) {
|
||||
} else {
|
||||
acc.total++;
|
||||
if (
|
||||
column.accessorKey in columnVisibility &&
|
||||
columnVisibility[column.accessorKey]
|
||||
(column.accessorKey in columnVisibility &&
|
||||
columnVisibility[column.accessorKey]) ||
|
||||
!column.enableHiding
|
||||
) {
|
||||
acc.count++;
|
||||
}
|
||||
@@ -323,8 +323,8 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
asChild
|
||||
>
|
||||
<Button variant="outline" title="Show/hide columns">
|
||||
<Columns className="mr-2 h-4 w-4" />
|
||||
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
|
||||
<span>Columns</span>
|
||||
<div className="ml-1 rounded-sm bg-input px-1 text-xs">{`${count}/${total}`}</div>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export function DataTableSelectAllBanner({
|
||||
totalCount,
|
||||
}: MultiSelect) {
|
||||
return (
|
||||
<div className="my-2 flex flex-wrap items-center justify-center gap-2 rounded-sm bg-input p-2 @container">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-center gap-2 rounded-sm bg-input p-2 @container">
|
||||
{selectAll ? (
|
||||
<span className="text-sm">
|
||||
All <span className="font-semibold">{totalCount}</span> items are
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { Rows3 } from "lucide-react";
|
||||
import {
|
||||
MdDensityLarge,
|
||||
MdDensityMedium,
|
||||
@@ -39,30 +49,34 @@ export const DataTableRowHeightSwitch = ({
|
||||
}) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
return (
|
||||
<Tabs
|
||||
//defaultValue={height}
|
||||
value={rowHeight}
|
||||
onValueChange={(e) => {
|
||||
capture("table:row_height_switch_select", {
|
||||
rowHeight: e,
|
||||
});
|
||||
setRowHeight(e as any);
|
||||
}}
|
||||
key="height"
|
||||
>
|
||||
<TabsList className="gap-1 border bg-transparent px-2">
|
||||
{heightOptions.map(({ id, label, icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className="px-2 shadow-none data-[state=active]:bg-input data-[state=active]:ring-border"
|
||||
>
|
||||
<span role="img" aria-label={`${label} size`}>
|
||||
{icon}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" title="Row height">
|
||||
<Rows3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Row height</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{heightOptions.map(({ id, label }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={id}
|
||||
checked={rowHeight === id}
|
||||
onClick={(e) => {
|
||||
// Prevent closing the dropdown menu to allow the user to adjust their selection
|
||||
e.preventDefault();
|
||||
capture("table:row_height_switch_select", {
|
||||
rowHeight: id,
|
||||
});
|
||||
setRowHeight(id);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/src/utils/date-range-utils";
|
||||
import { DataTableSelectAllBanner } from "@/src/components/table/data-table-multi-select-actions/data-table-select-all-banner";
|
||||
import { MultiSelect } from "@/src/features/filters/components/multi-select";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export interface MultiSelect {
|
||||
selectAll: boolean;
|
||||
@@ -68,6 +69,7 @@ interface DataTableToolbarProps<TData, TValue> {
|
||||
onValueChange: (values: string[]) => void;
|
||||
options: { value: string }[];
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData, TValue>({
|
||||
@@ -88,6 +90,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
setDateRangeAndOption,
|
||||
multiSelect,
|
||||
environmentFilter,
|
||||
className,
|
||||
}: DataTableToolbarProps<TData, TValue>) {
|
||||
const [searchString, setSearchString] = useState(
|
||||
searchConfig?.currentQuery ?? "",
|
||||
@@ -95,10 +98,20 @@ export function DataTableToolbar<TData, TValue>({
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("grid h-fit w-full gap-0 px-2", className)}>
|
||||
<div className="my-2 flex flex-wrap items-center gap-2 @container">
|
||||
{searchConfig && (
|
||||
<div className="flex max-w-md items-center">
|
||||
<div className="flex max-w-md items-center rounded-md border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
capture("table:search_submit");
|
||||
searchConfig.updateQuery(searchString);
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={searchConfig.placeholder}
|
||||
@@ -110,20 +123,16 @@ export function DataTableToolbar<TData, TValue>({
|
||||
searchConfig.updateQuery(searchString);
|
||||
}
|
||||
}}
|
||||
className="w-[150px] rounded-r-none @6xl:w-[250px]"
|
||||
className="min-w-0 max-w-fit border-none px-0"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
capture("table:search_submit");
|
||||
searchConfig.updateQuery(searchString);
|
||||
}}
|
||||
className="rounded-l-none border-l-0 p-3"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedOption && setDateRangeAndOption && (
|
||||
<TableDateRangeDropdown
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
/>
|
||||
)}
|
||||
{!!filterColumnDefinition && !!filterState && !!setFilterState && (
|
||||
<PopoverFilterBuilder
|
||||
columns={filterColumnDefinition}
|
||||
@@ -142,12 +151,7 @@ export function DataTableToolbar<TData, TValue>({
|
||||
className="my-0 w-auto overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{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
|
||||
@@ -172,6 +176,6 @@ export function DataTableToolbar<TData, TValue>({
|
||||
multiSelect.selectedRowIds.length === multiSelect.pageSize && (
|
||||
<DataTableSelectAllBanner {...multiSelect} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +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";
|
||||
import {
|
||||
@@ -32,6 +31,16 @@ import {
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
TablePeekView,
|
||||
type DataTablePeekViewProps,
|
||||
} from "@/src/components/table/peek";
|
||||
|
||||
type PeekViewProps<TData> = Omit<
|
||||
DataTablePeekViewProps<TData>,
|
||||
"selectedRowId"
|
||||
>;
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: LangfuseColumnDef<TData, TValue>[];
|
||||
@@ -53,10 +62,10 @@ interface DataTableProps<TData, TValue> {
|
||||
help?: { description: string; href: string };
|
||||
rowHeight?: RowHeight;
|
||||
className?: string;
|
||||
paginationClassName?: string;
|
||||
isBorderless?: boolean;
|
||||
shouldRenderGroupHeaders?: boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
peekView?: PeekViewProps<TData>;
|
||||
pinFirstColumn?: boolean;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -106,14 +115,16 @@ export function DataTable<TData extends object, TValue>({
|
||||
setOrderBy,
|
||||
rowHeight,
|
||||
className,
|
||||
paginationClassName,
|
||||
isBorderless = false,
|
||||
shouldRenderGroupHeaders = false,
|
||||
onRowClick,
|
||||
peekView,
|
||||
pinFirstColumn = false,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
const peekViewId = router.query.peek as string | undefined;
|
||||
|
||||
const flattedColumnsByGroup = useMemo(() => {
|
||||
const flatColumnsByGroup = new Map<string, string[]>();
|
||||
@@ -127,6 +138,24 @@ export function DataTable<TData extends object, TValue>({
|
||||
return flatColumnsByGroup;
|
||||
}, [columns]);
|
||||
|
||||
const handleOnRowClick = (row: TData) => {
|
||||
if (peekView) {
|
||||
const rowId =
|
||||
"id" in row && typeof row.id === "string" ? row.id : undefined;
|
||||
|
||||
// If clicking the same row that's already open, close it
|
||||
if (rowId === peekViewId) {
|
||||
peekView.onOpenChange(false);
|
||||
}
|
||||
// If clicking a different row, update without closing first
|
||||
else {
|
||||
peekView.onOpenChange(true, row);
|
||||
}
|
||||
}
|
||||
onRowClick?.(row);
|
||||
};
|
||||
const hasRowClickAction = !!onRowClick || !!peekView;
|
||||
|
||||
const table = useReactTable({
|
||||
data: data.data ?? [],
|
||||
columns,
|
||||
@@ -200,15 +229,12 @@ export function DataTable<TData extends object, TValue>({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full flex-1 flex-col gap-1 overflow-auto",
|
||||
"flex w-full max-w-full flex-1 flex-col overflow-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-auto",
|
||||
isBorderless ? "" : "rounded-md border",
|
||||
)}
|
||||
className={cn("relative w-full overflow-auto border-t")}
|
||||
style={{ ...columnSizeVars }}
|
||||
>
|
||||
<Table>
|
||||
@@ -234,6 +260,9 @@ export function DataTable<TData extends object, TValue>({
|
||||
className={cn(
|
||||
"group p-1 first:pl-2",
|
||||
sortingEnabled && "cursor-pointer",
|
||||
pinFirstColumn &&
|
||||
header.index === 0 &&
|
||||
"sticky left-0 z-20 border-r bg-background",
|
||||
)}
|
||||
style={{ width }}
|
||||
onClick={(event) => {
|
||||
@@ -321,7 +350,9 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
peekViewId={peekViewId}
|
||||
pinFirstColumn={pinFirstColumn}
|
||||
/>
|
||||
) : (
|
||||
<TableBodyComponent
|
||||
@@ -330,18 +361,22 @@ export function DataTable<TData extends object, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
help={help}
|
||||
onRowClick={onRowClick}
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
peekViewId={peekViewId}
|
||||
pinFirstColumn={pinFirstColumn}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<div className="grow"></div>
|
||||
</div>
|
||||
{peekView && peekViewId && (
|
||||
<TablePeekView selectedRowId={peekViewId} {...peekView} />
|
||||
)}
|
||||
{pagination !== undefined ? (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-10 flex w-full justify-end bg-background font-medium",
|
||||
paginationClassName,
|
||||
"sticky bottom-0 z-10 flex w-full justify-end border-t bg-background py-2 pr-2 font-medium",
|
||||
)}
|
||||
>
|
||||
<DataTablePagination
|
||||
@@ -373,6 +408,8 @@ interface TableBodyComponentProps<TData> {
|
||||
data: AsyncTableData<TData[]>;
|
||||
help?: { description: string; href: string };
|
||||
onRowClick?: (row: TData) => void;
|
||||
peekViewId?: string;
|
||||
pinFirstColumn?: boolean;
|
||||
}
|
||||
|
||||
function TableBodyComponent<TData>({
|
||||
@@ -382,6 +419,8 @@ function TableBodyComponent<TData>({
|
||||
data,
|
||||
help,
|
||||
onRowClick,
|
||||
peekViewId,
|
||||
pinFirstColumn = false,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
@@ -398,10 +437,18 @@ function TableBodyComponent<TData>({
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-row-index={row.index}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={
|
||||
onRowClick ? "cursor-pointer hover:bg-accent" : undefined
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onRowClick?.(row.original);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-accent",
|
||||
!!onRowClick ? "cursor-pointer" : "cursor-default",
|
||||
peekViewId && peekViewId === row.id ? "bg-accent" : undefined,
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
@@ -409,6 +456,9 @@ function TableBodyComponent<TData>({
|
||||
className={cn(
|
||||
"overflow-hidden border-b p-1 text-xs first:pl-2",
|
||||
rowheighttw === "s" && "whitespace-nowrap",
|
||||
pinFirstColumn &&
|
||||
cell.column.getIndex() === 0 &&
|
||||
"sticky left-0 border-r bg-background",
|
||||
)}
|
||||
style={{
|
||||
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/src/components/ui/sheet";
|
||||
import { Expand, ExternalLink } from "lucide-react";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { ItemBadge, type LangfuseItemType } from "@/src/components/ItemBadge";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type PeekViewItemType = Extract<LangfuseItemType, "TRACE">;
|
||||
|
||||
export type DataTablePeekViewProps<TData> = {
|
||||
itemType: PeekViewItemType;
|
||||
selectedRowId: string | null;
|
||||
onOpenChange: (open: boolean, row?: TData) => void;
|
||||
onExpand: (openInNewTab: boolean) => void;
|
||||
render: () => React.ReactNode;
|
||||
};
|
||||
|
||||
const mapItemTypeToPageUrl: Record<PeekViewItemType, string> = {
|
||||
TRACE: "traces",
|
||||
} as const;
|
||||
|
||||
export function TablePeekView<TData>({
|
||||
itemType,
|
||||
selectedRowId,
|
||||
onOpenChange,
|
||||
onExpand,
|
||||
render,
|
||||
}: DataTablePeekViewProps<TData>) {
|
||||
const router = useRouter();
|
||||
const pageUrl = mapItemTypeToPageUrl[itemType];
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={!!selectedRowId}
|
||||
onOpenChange={(open) => {
|
||||
// Ignore close events from checkbox or bookmark star toggle clicks to ensure integrity of table row actions
|
||||
if (
|
||||
!open &&
|
||||
(document.activeElement?.closest('[role="checkbox"]') ||
|
||||
document.activeElement?.closest('[aria-label="bookmark"]'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(open);
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<SheetContent
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent the default behavior of closing when clicking outside when we set modal={false}
|
||||
e.preventDefault();
|
||||
}}
|
||||
side="right"
|
||||
className="flex max-h-full min-h-0 min-w-[60vw] flex-col gap-0 overflow-hidden rounded-l-xl p-0"
|
||||
>
|
||||
<SheetHeader className="flex min-h-12 flex-row justify-between rounded-t-xl bg-header px-2">
|
||||
<SheetTitle className="!mt-0 ml-2 flex flex-row items-center gap-2">
|
||||
<ItemBadge type={itemType} showLabel />
|
||||
<span
|
||||
className="text-sm font-medium focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
{selectedRowId}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<div className="!mt-0 flex flex-row items-center gap-2">
|
||||
{selectedRowId && (
|
||||
<DetailPageNav
|
||||
currentId={selectedRowId}
|
||||
path={(entry) => {
|
||||
const { projectId } = router.query;
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// Update the path part
|
||||
url.pathname = `/project/${projectId as string}/${pageUrl}`;
|
||||
|
||||
// Keep all existing query params
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
// Update timestamp if it exists in entry.params
|
||||
if (entry.params) {
|
||||
if (entry.params.timestamp)
|
||||
params.set(
|
||||
"timestamp",
|
||||
encodeURIComponent(entry.params.timestamp),
|
||||
);
|
||||
params.delete("observation");
|
||||
}
|
||||
|
||||
// Update peek param to the new id
|
||||
params.set("peek", entry.id);
|
||||
|
||||
// Set the search part of the URL
|
||||
return `${url.pathname}?${params.toString()}`;
|
||||
}}
|
||||
listKey={pageUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="!mt-0 mr-8 flex h-full flex-row items-center gap-1 border-l">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Open in current tab"
|
||||
className="ml-2"
|
||||
onClick={() => onExpand(false)}
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Open in new tab"
|
||||
onClick={() => onExpand(true)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<div className="flex max-h-full min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-auto">{render()}</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export default function TableId({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
title={value}
|
||||
className={cn(
|
||||
"inline-block max-w-full overflow-hidden text-ellipsis text-nowrap rounded py-0.5 text-xs font-semibold",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { UpsertModelFormDrawer } from "@/src/features/models/components/UpsertMo
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
|
||||
|
||||
export type ModelTableRow = {
|
||||
modelId: string;
|
||||
@@ -290,39 +291,41 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
</ActionButton>
|
||||
</UpsertModelFormDrawer>
|
||||
}
|
||||
className="px-0"
|
||||
/>
|
||||
<DataTable
|
||||
className="flex max-h-[60dvh] flex-col overflow-hidden"
|
||||
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={{
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
onRowClick={(row) => {
|
||||
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
|
||||
}}
|
||||
/>
|
||||
<SettingsTableCard>
|
||||
<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={{
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
onRowClick={(row) => {
|
||||
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
|
||||
}}
|
||||
/>
|
||||
</SettingsTableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function ObservationsTable({
|
||||
accessorKey: "type",
|
||||
id: "type",
|
||||
header: "Type",
|
||||
size: 100,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: ObservationType = row.getValue("type");
|
||||
@@ -856,7 +856,7 @@ export default function ObservationsTable({
|
||||
filterState={inputFilterState}
|
||||
setFilterState={useDebounce(setInputFilterState)}
|
||||
searchConfig={{
|
||||
placeholder: "Search by id, name, traceName, model",
|
||||
placeholder: "Search (by id, name, trace name, model)",
|
||||
updateQuery: setSearchQuery,
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -29,6 +28,8 @@ import {
|
||||
} from "@/src/components/ui/popover";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
import { CreateScoreConfigButton } from "@/src/features/scores/components/CreateScoreConfigButton";
|
||||
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
|
||||
|
||||
type ScoreConfigTableRow = {
|
||||
id: string;
|
||||
@@ -248,8 +249,10 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
setColumnOrder={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
actionButtons={<CreateScoreConfigButton projectId={projectId} />}
|
||||
className="px-0"
|
||||
/>
|
||||
<Card className="mb-4 flex max-h-[60dvh] flex-col overflow-hidden">
|
||||
<SettingsTableCard>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
@@ -291,10 +294,8 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
className="gap-2"
|
||||
paginationClassName="-mx-2 mb-2"
|
||||
isBorderless
|
||||
/>
|
||||
</Card>
|
||||
</SettingsTableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StarTraceToggle } from "@/src/components/star-toggle";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { TagTracePopover } from "@/src/features/tag/components/TagTracePopver";
|
||||
@@ -75,6 +74,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Trace } from "@/src/components/trace";
|
||||
import router from "next/router";
|
||||
import TableId from "@/src/components/table/table-id";
|
||||
import {
|
||||
useEnvironmentFilter,
|
||||
convertSelectedEnvironmentsToFilter,
|
||||
@@ -122,6 +124,7 @@ export type TracesTableProps = {
|
||||
projectId: string;
|
||||
userId?: string;
|
||||
omittedFilter?: string[];
|
||||
pinFirstColumn?: boolean;
|
||||
};
|
||||
|
||||
export default function TracesTable({
|
||||
@@ -130,13 +133,17 @@ export default function TracesTable({
|
||||
omittedFilter = [],
|
||||
}: TracesTableProps) {
|
||||
const utils = api.useUtils();
|
||||
const [peekViewId] = useQueryParam("peek", withDefault(StringParam, null));
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, null),
|
||||
);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"display",
|
||||
withDefault(StringParam, "details"),
|
||||
);
|
||||
const { selectedOption, dateRange, setDateRangeAndOption } =
|
||||
useTableDateRange(projectId);
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
@@ -441,14 +448,9 @@ export default function TracesTable({
|
||||
isPinned: true,
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["id"] = row.getValue("id");
|
||||
const timestamp: TracesTableRow["timestamp"] =
|
||||
row.getValue("timestamp");
|
||||
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/traces/${encodeURIComponent(value)}?timestamp=${encodeURIComponent(timestamp.toISOString())}`}
|
||||
value={value}
|
||||
/>
|
||||
<TableId value={value} />
|
||||
) : undefined;
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -504,10 +506,7 @@ export default function TracesTable({
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["userId"] = row.getValue("userId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
/>
|
||||
<TableId value={value} />
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -526,10 +525,7 @@ export default function TracesTable({
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["sessionId"] = row.getValue("sessionId");
|
||||
return value && typeof value === "string" ? (
|
||||
<TableLink
|
||||
path={`/project/${projectId}/sessions/${encodeURIComponent(value)}`}
|
||||
value={value}
|
||||
/>
|
||||
<TableId value={value} />
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
@@ -544,7 +540,9 @@ export default function TracesTable({
|
||||
cell: ({ row }) => {
|
||||
const value: TracesTableRow["latency"] = row.getValue("latency");
|
||||
if (!traceMetrics.data) return <Skeleton className="h-3 w-1/2" />;
|
||||
return value !== undefined ? formatIntervalSeconds(value) : undefined;
|
||||
return value !== undefined ? (
|
||||
<span>{formatIntervalSeconds(value)}</span>
|
||||
) : undefined;
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
@@ -969,7 +967,7 @@ export default function TracesTable({
|
||||
columns={columns}
|
||||
filterColumnDefinition={transformFilterOptions(traceFilterOptions.data)}
|
||||
searchConfig={{
|
||||
placeholder: "Search by id, name, user id",
|
||||
placeholder: "Search (by id, name, trace name, user id)",
|
||||
updateQuery: setSearchQuery,
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
}}
|
||||
@@ -1048,6 +1046,96 @@ export default function TracesTable({
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
pinFirstColumn
|
||||
peekView={{
|
||||
itemType: "TRACE",
|
||||
onOpenChange: (open: boolean, row?: TracesTableRow) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
if (!open || !row) {
|
||||
// Remove peek and timestamp params while keeping others
|
||||
params.delete("peek");
|
||||
params.delete("timestamp");
|
||||
params.delete("observation");
|
||||
params.delete("display");
|
||||
} else if (open && row.id && peekViewId !== row.id) {
|
||||
// Update or add peek and timestamp params
|
||||
params.set("peek", row.id);
|
||||
params.set("timestamp", row.timestamp.toISOString());
|
||||
params.delete("observation");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(
|
||||
{
|
||||
pathname: `/project/${projectId}/traces`,
|
||||
query: params.toString(),
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
},
|
||||
onExpand: (openInNewTab: boolean) => {
|
||||
if (peekViewId) {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const timestamp = params.get("timestamp");
|
||||
const display = params.get("display") ?? "details";
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(
|
||||
`/project/${projectId}/traces/${encodeURIComponent(peekViewId)}?timestamp=${timestamp}&display=${display}`,
|
||||
"_blank",
|
||||
);
|
||||
} else {
|
||||
router.replace(
|
||||
`/project/${projectId}/traces/${encodeURIComponent(peekViewId)}?timestamp=${timestamp}&display=${display}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: () => {
|
||||
const { peek, timestamp } = router.query;
|
||||
|
||||
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
|
||||
{
|
||||
traceId: peek as string,
|
||||
timestamp:
|
||||
typeof timestamp === "string"
|
||||
? new Date(timestamp)
|
||||
: undefined,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!peek && !!timestamp,
|
||||
retry(failureCount, error) {
|
||||
if (
|
||||
error.data?.code === "UNAUTHORIZED" ||
|
||||
error.data?.code === "NOT_FOUND"
|
||||
)
|
||||
return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return !trace.data ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<Trace
|
||||
key={trace.data.id}
|
||||
trace={trace.data}
|
||||
scores={trace.data.scores}
|
||||
projectId={trace.data.projectId}
|
||||
observations={trace.data.observations}
|
||||
selectedTab={selectedTab}
|
||||
setSelectedTab={setSelectedTab}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { z } from "zod";
|
||||
import { type Prisma, deepParseJson } from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Fragment } from "react";
|
||||
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
|
||||
@@ -226,26 +226,42 @@ export const OpenAiMessageView: React.FC<{
|
||||
collapseLongHistory && messages.length > COLLAPSE_THRESHOLD ? true : null,
|
||||
);
|
||||
|
||||
const shouldRenderContent = (message: ChatMlMessageSchema) => {
|
||||
return message.content != null || !!message.audio;
|
||||
};
|
||||
|
||||
const shouldRenderJson = (message: ChatMlMessageSchema) => {
|
||||
return !!message.json;
|
||||
};
|
||||
|
||||
const messagesToRender = useMemo(
|
||||
() =>
|
||||
messages.filter(
|
||||
(message) => shouldRenderContent(message) || shouldRenderJson(message),
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ph-no-capture flex max-h-full min-h-0 flex-col gap-2">
|
||||
{title && <SubHeaderLabel title={title} className="mt-1" />}
|
||||
<div className="flex max-h-full min-h-0 flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages
|
||||
{messagesToRender
|
||||
.filter(
|
||||
(_, i) =>
|
||||
// show all if not collapsed or null; show first and last n if collapsed
|
||||
!isCollapsed ||
|
||||
i == 0 ||
|
||||
i > messages.length - COLLAPSE_THRESHOLD,
|
||||
i > messagesToRender.length - COLLAPSE_THRESHOLD,
|
||||
)
|
||||
.map((message, index) => (
|
||||
<Fragment key={index}>
|
||||
{(!!message.content || !!message.audio) &&
|
||||
{shouldRenderContent(message) &&
|
||||
(shouldRenderMarkdown ? (
|
||||
<MarkdownJsonView
|
||||
title={message.name ?? message.role}
|
||||
content={message.content}
|
||||
content={message.content || '""'}
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
customCodeHeaderClassName={cn(
|
||||
message.role === "assistant" && "bg-secondary",
|
||||
@@ -261,7 +277,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
/>
|
||||
))}
|
||||
{!!message.json && (
|
||||
{shouldRenderJson(message) && (
|
||||
<JSONView
|
||||
title={
|
||||
message.content
|
||||
@@ -282,7 +298,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{isCollapsed
|
||||
? `Show ${messages.length - COLLAPSE_THRESHOLD} more ...`
|
||||
? `Show ${messagesToRender.length - COLLAPSE_THRESHOLD} more ...`
|
||||
: "Hide history"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
calculateDisplayTotalCost,
|
||||
heatMapTextColor,
|
||||
nestObservations,
|
||||
treeItemColors,
|
||||
unnestObservation,
|
||||
} from "@/src/components/trace/lib/helpers";
|
||||
import { CommentCountIcon } from "@/src/features/comments/CommentCountIcon";
|
||||
@@ -110,18 +109,19 @@ export const ObservationTree = ({
|
||||
{props.minLevel && hiddenObservationsCount > 0 ? (
|
||||
<span className="flex items-center gap-1 p-2 py-4">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex flex-row gap-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{hiddenObservationsCount} observations below {props.minLevel}{" "}
|
||||
level are hidden.
|
||||
</p>
|
||||
<p
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span>
|
||||
{hiddenObservationsCount}{" "}
|
||||
{hiddenObservationsCount === 1 ? "observation" : "observations"}{" "}
|
||||
below {props.minLevel} level are hidden.{" "}
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer underline"
|
||||
onClick={() => props.setMinLevel?.(ObservationLevel.DEBUG)}
|
||||
>
|
||||
Show all
|
||||
</p>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -512,14 +512,5 @@ const ObservationTreeNodeCard = ({
|
||||
export const ColorCodedObservationType = (props: {
|
||||
observationType: ObservationType;
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"self-start rounded-sm px-1 py-0.5 text-xs",
|
||||
treeItemColors.get(props.observationType),
|
||||
)}
|
||||
>
|
||||
{props.observationType}
|
||||
</span>
|
||||
);
|
||||
return <ItemBadge type={props.observationType} showLabel />;
|
||||
};
|
||||
|
||||
@@ -91,7 +91,6 @@ export function TracePage({
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding={false}
|
||||
headerProps={{
|
||||
title: trace.data.name
|
||||
? `${trace.data.name}: ${trace.data.id}`
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import {
|
||||
ObservationLevel,
|
||||
type ObservationLevelType,
|
||||
ObservationType,
|
||||
} from "@langfuse/shared";
|
||||
import { type NestedObservation } from "@/src/utils/types";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import Decimal from "decimal.js";
|
||||
import {
|
||||
type ObservationType,
|
||||
type ObservationLevelType,
|
||||
ObservationLevel,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
export type TreeItemType = ObservationType | "TRACE";
|
||||
|
||||
export const treeItemColors: Map<TreeItemType, string> = new Map([
|
||||
[ObservationType.SPAN, "bg-muted-blue"],
|
||||
[ObservationType.GENERATION, "bg-muted-magenta"],
|
||||
[ObservationType.EVENT, "bg-muted-green"],
|
||||
["TRACE", "bg-input"],
|
||||
]);
|
||||
|
||||
export function nestObservations(
|
||||
list: ObservationReturnType[],
|
||||
minLevel?: ObservationLevelType,
|
||||
|
||||
@@ -21,7 +21,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-200",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { SubHeader } from "@/src/components/layouts/header";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import React from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/src/components/ui/sheet";
|
||||
import { useIsMobile } from "@/src/hooks/use-mobile";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import useSessionStorage from "@/src/components/useSessionStorage";
|
||||
|
||||
const SidePanelContext = React.createContext<{
|
||||
showPanel: boolean;
|
||||
setShowPanel: (value: boolean) => void;
|
||||
isMobile: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const SidePanel = ({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
mobileTitle,
|
||||
}: {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mobileTitle?: string;
|
||||
}) => {
|
||||
const [showPanel, setShowPanel] = useSessionStorage<boolean>(
|
||||
`${id}-showPanel`,
|
||||
true,
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
showPanel,
|
||||
setShowPanel,
|
||||
isMobile,
|
||||
}),
|
||||
[showPanel, setShowPanel, isMobile],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="border-l px-1 pt-2">
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{mobileTitle}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-2 flex h-full w-full flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-row overflow-hidden border-l",
|
||||
showPanel ? "w-[25vw]" : "w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="grid h-full w-full items-start gap-2 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col gap-2",
|
||||
showPanel ? "p-2" : "p-1 pr-2 pt-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanelContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelHeader = ({ children }: { children: ReactNode }) => {
|
||||
const context = React.useContext(SidePanelContext);
|
||||
|
||||
// Don't throw if we're in mobile mode (no context)
|
||||
if (!context) return null;
|
||||
|
||||
const { showPanel, setShowPanel } = context;
|
||||
|
||||
if (!showPanel) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPanel(true)}
|
||||
title="Show details"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex h-fit w-full flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-1">{children}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowPanel(false)}
|
||||
title="Hide details"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelTitle = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => <SubHeader title={children?.toString() ?? ""} className={className} />;
|
||||
|
||||
const SidePanelContent = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const context = React.useContext(SidePanelContext);
|
||||
if (!context) return children;
|
||||
|
||||
const { showPanel } = context;
|
||||
if (!showPanel) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start gap-4", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { SidePanel, SidePanelHeader, SidePanelTitle, SidePanelContent };
|
||||
@@ -88,7 +88,11 @@ const TableCell = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn(
|
||||
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
"border-b [:last-child_>_&]:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.43.0";
|
||||
export const VERSION = "v3.45.2";
|
||||
|
||||
@@ -47,6 +47,7 @@ export const AnnotationQueuesItem = ({
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
headerProps={{
|
||||
title: `${queue.data?.name}: ${itemId}`,
|
||||
itemType: "QUEUE_ITEM",
|
||||
|
||||
@@ -236,9 +236,6 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
|
||||
setColumnOrder={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
actionButtons={
|
||||
<CreateOrEditAnnotationQueueButton projectId={projectId} />
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AnnotationQueueItemsTable } from "@/src/ee/features/annotation-queues/c
|
||||
import { CardDescription } from "@/src/components/ui/card";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { ClipboardPen, Lock } from "lucide-react";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { getScoreDataTypeIcon } from "@/src/features/scores/components/ScoreDetailColumnHelpers";
|
||||
import Link from "next/link";
|
||||
@@ -13,8 +12,14 @@ import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAcces
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/SupportOrUpgradePage";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { TableWithMetadataWrapper } from "@/src/components/table/TableWithMetadataWrapper";
|
||||
import Page from "@/src/components/layouts/page";
|
||||
import {
|
||||
SidePanel,
|
||||
SidePanelContent,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle,
|
||||
} from "@/src/components/ui/side-panel";
|
||||
import { SubHeaderLabel } from "@/src/components/layouts/header";
|
||||
|
||||
export default function QueueItems() {
|
||||
const router = useRouter();
|
||||
@@ -65,43 +70,49 @@ export default function QueueItems() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TableWithMetadataWrapper
|
||||
tableComponent={
|
||||
<div className="grid flex-1 grid-cols-[1fr,auto] overflow-hidden">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<AnnotationQueueItemsTable projectId={projectId} queueId={queueId} />
|
||||
}
|
||||
cardTitleChildren={
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
{queue.data ? (
|
||||
<span>{queue.data.name}</span>
|
||||
) : (
|
||||
<Skeleton className="h-full w-1/2" />
|
||||
)}
|
||||
</div>
|
||||
<SidePanel
|
||||
mobileTitle={queue.data?.name ?? "Queue details"}
|
||||
id="queue-details"
|
||||
>
|
||||
<SidePanelHeader>
|
||||
<SidePanelTitle>
|
||||
{queue.data?.name ?? "Queue details"}
|
||||
</SidePanelTitle>
|
||||
<CreateOrEditAnnotationQueueButton
|
||||
projectId={projectId}
|
||||
queueId={queueId}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
cardContentChildren={
|
||||
<>
|
||||
{queue.data?.description && (
|
||||
<CardDescription className="text-sm">
|
||||
{queue.data?.description}
|
||||
</CardDescription>
|
||||
</SidePanelHeader>
|
||||
<SidePanelContent>
|
||||
{queue.isLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<>
|
||||
{queue.data?.description && (
|
||||
<CardDescription className="text-sm">
|
||||
{queue.data?.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<SubHeaderLabel title="Score Configs" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queue.data?.scoreConfigs.map((scoreConfig) => (
|
||||
<Badge key={scoreConfig.id} variant="outline">
|
||||
{getScoreDataTypeIcon(scoreConfig.dataType)}
|
||||
<span className="ml-0.5">{scoreConfig.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Separator orientation="horizontal" />
|
||||
<h5 className="text-md font-bold leading-7 sm:tracking-tight">
|
||||
Score Configs
|
||||
</h5>
|
||||
{queue.data?.scoreConfigs.map((scoreConfig) => (
|
||||
<Badge key={scoreConfig.id} className="mr-2" variant="outline">
|
||||
{getScoreDataTypeIcon(scoreConfig.dataType)}
|
||||
<span className="ml-0.5">{scoreConfig.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SidePanelContent>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/Suppo
|
||||
import Page from "@/src/components/layouts/page";
|
||||
import { AnnotationQueuesOnboarding } from "@/src/components/onboarding/AnnotationQueuesOnboarding";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { CreateOrEditAnnotationQueueButton } from "@/src/ee/features/annotation-queues/components/CreateOrEditAnnotationQueueButton";
|
||||
|
||||
export default function AnnotationQueues() {
|
||||
const router = useRouter();
|
||||
@@ -43,6 +44,12 @@ export default function AnnotationQueues() {
|
||||
"Annotation queues are used to manage scoring workflows for your LLM projects. See docs to learn more.",
|
||||
href: "https://langfuse.com/docs/scores/annotation",
|
||||
},
|
||||
actionButtonsRight: (
|
||||
<CreateOrEditAnnotationQueueButton
|
||||
projectId={projectId}
|
||||
variant="default"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scrollable={showOnboarding}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AuditLogsSettingsPage(props: { projectId: string }) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Audit Logs" />
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Track who changed what in your project and when. Monitor settings,
|
||||
configurations, and data changes over time. Reach out to the Langfuse
|
||||
team if you require more detailed/filtered audit logs.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { type RouterOutputs } from "@/src/utils/api";
|
||||
import { SettingsTableCard } from "@/src/components/layouts/settings-table-card";
|
||||
|
||||
type AuditLogRow = RouterOutputs["auditLogs"]["all"]["data"][number];
|
||||
|
||||
@@ -122,31 +123,34 @@ export function AuditLogsTable(props: { projectId: string }) {
|
||||
columns={columns}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
className="px-0"
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
auditLogs.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: auditLogs.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: auditLogs.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: auditLogs.data.data,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount: auditLogs.data?.totalCount ?? 0,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
<SettingsTableCard>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
auditLogs.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: auditLogs.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: auditLogs.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: auditLogs.data.data,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount: auditLogs.data?.totalCount ?? 0,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</SettingsTableCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
|
||||
export function VariableMappingDescription(p: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeScopeDescription(props: {
|
||||
projectId: string;
|
||||
timeScope: ("NEW" | "EXISTING")[] | undefined;
|
||||
target: "trace" | "dataset_item" | undefined;
|
||||
}) {
|
||||
if (!props.timeScope || props.timeScope.length === 0) {
|
||||
return "Select a time scope to run this configuration on.";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
This configuration will run on{" "}
|
||||
{props.timeScope?.includes("NEW") && props.timeScope?.includes("EXISTING")
|
||||
? "all future and existing"
|
||||
: props.timeScope?.includes("NEW")
|
||||
? "all future"
|
||||
: "all existing"}{" "}
|
||||
{props.target === "trace" ? "traces" : "dataset run items"} that match
|
||||
these filters.{" "}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const EvalTemplateDetail = () => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
scrollable
|
||||
headerProps={{
|
||||
title: `${template.data?.name}: ${templateId}`,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useState } from "react";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import EvalLogTable from "@/src/ee/features/evals/components/eval-log";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { TableWithMetadataWrapper } from "@/src/components/table/TableWithMetadataWrapper";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { CardDescription } from "@/src/components/ui/card";
|
||||
@@ -27,6 +26,12 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import Page from "@/src/components/layouts/page";
|
||||
import {
|
||||
SidePanel,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle,
|
||||
} from "@/src/components/ui/side-panel";
|
||||
import { SidePanelContent } from "@/src/components/ui/side-panel";
|
||||
import { LevelCountsDisplay } from "@/src/components/level-counts-display";
|
||||
import {
|
||||
type JobExecutionState,
|
||||
@@ -140,19 +145,22 @@ export const EvaluatorDetail = () => {
|
||||
}}
|
||||
>
|
||||
{existingEvaluator && (
|
||||
<TableWithMetadataWrapper
|
||||
tableComponent={
|
||||
<div className="grid flex-1 grid-cols-[1fr,auto] overflow-hidden">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<EvalLogTable
|
||||
projectId={projectId}
|
||||
jobConfigurationId={existingEvaluator.id}
|
||||
/>
|
||||
}
|
||||
cardTitleChildren={
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<span>Evaluator configuration</span>
|
||||
</div>
|
||||
<SidePanel
|
||||
mobileTitle="Evaluator configuration"
|
||||
id="evaluator-configuration"
|
||||
>
|
||||
<SidePanelHeader>
|
||||
<SidePanelTitle>Evaluator configuration</SidePanelTitle>
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -175,32 +183,33 @@ export const EvaluatorDetail = () => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
cardContentChildren={
|
||||
<>
|
||||
<CardDescription className="flex items-center justify-between text-sm">
|
||||
<span className="text-sm font-medium">Eval Template</span>
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
|
||||
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
|
||||
className="flex min-h-6 items-center"
|
||||
/>
|
||||
</CardDescription>
|
||||
|
||||
<div className="flex w-full flex-col items-start justify-between space-y-2 pb-4">
|
||||
<EvaluatorForm
|
||||
key={existingEvaluator.id}
|
||||
projectId={projectId}
|
||||
evalTemplates={allTemplates.data?.templates}
|
||||
existingEvaluator={existingEvaluator}
|
||||
disabled={true}
|
||||
shouldWrapVariables={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SidePanelHeader>
|
||||
<SidePanelContent>
|
||||
<>
|
||||
<CardDescription className="flex items-center justify-between text-sm">
|
||||
<span className="mr-2 text-sm font-medium">
|
||||
Eval Template
|
||||
</span>
|
||||
<TableLink
|
||||
path={`/project/${projectId}/evals/templates/${existingEvaluator.evalTemplateId}`}
|
||||
value={`${existingEvaluator.evalTemplate.name} (v${existingEvaluator.evalTemplate.version})`}
|
||||
className="flex min-h-6 items-center"
|
||||
/>
|
||||
</CardDescription>
|
||||
<div className="flex max-h-[80dvh] w-full flex-col items-start justify-between space-y-2 overflow-y-auto pb-4">
|
||||
<EvaluatorForm
|
||||
key={existingEvaluator.id}
|
||||
projectId={projectId}
|
||||
evalTemplates={allTemplates.data?.templates}
|
||||
existingEvaluator={existingEvaluator}
|
||||
disabled={true}
|
||||
shouldWrapVariables={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SidePanelContent>
|
||||
</SidePanel>
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import {
|
||||
tracesTableColsWithOptions,
|
||||
evalTraceTableCols,
|
||||
evalDatasetFormFilterCols,
|
||||
singleFilter,
|
||||
type JobConfiguration,
|
||||
availableTraceEvalVariables,
|
||||
datasetFormFilterColsWithOptions,
|
||||
availableDatasetEvalVariables,
|
||||
type langfuseObjects,
|
||||
TimeScopeSchema,
|
||||
} from "@langfuse/shared";
|
||||
import * as z from "zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { type JobConfiguration } from "@langfuse/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { InlineFilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import {
|
||||
type EvalTemplate,
|
||||
variableMapping,
|
||||
wipVariableMapping,
|
||||
} from "@langfuse/shared";
|
||||
import router from "next/router";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { type EvalTemplate } from "@langfuse/shared";
|
||||
import { CheckIcon, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
@@ -66,31 +22,7 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
|
||||
import { EvalTemplateForm } from "@/src/ee/features/evals/components/template-form";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
|
||||
export const fieldHasJsonSelectorOption = (
|
||||
selectedColumnId: string | undefined | null,
|
||||
): boolean =>
|
||||
selectedColumnId === "input" ||
|
||||
selectedColumnId === "output" ||
|
||||
selectedColumnId === "expected_output";
|
||||
|
||||
const formSchema = z.object({
|
||||
scoreName: z.string(),
|
||||
target: z.string(),
|
||||
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
|
||||
mapping: z.array(wipVariableMapping),
|
||||
sampling: z.coerce.number().gt(0).lte(1),
|
||||
delay: z.coerce.number().optional().default(10),
|
||||
timeScope: TimeScopeSchema,
|
||||
});
|
||||
|
||||
type LangfuseObject = (typeof langfuseObjects)[number];
|
||||
|
||||
const isTraceTarget = (target: string): boolean => target === "trace";
|
||||
const isTraceOrDatasetObject = (object: LangfuseObject): boolean =>
|
||||
object === "trace" || object === "dataset_item";
|
||||
import { InnerEvaluatorForm } from "@/src/ee/features/evals/components/inner-evaluator-form";
|
||||
|
||||
export const EvaluatorForm = (props: {
|
||||
projectId: string;
|
||||
@@ -286,7 +218,7 @@ export const EvaluatorForm = (props: {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{evalTemplate && currentTemplate ? (
|
||||
<InnerEvalConfigForm
|
||||
<InnerEvaluatorForm
|
||||
key={evalTemplate}
|
||||
projectId={props.projectId}
|
||||
disabled={props.disabled}
|
||||
@@ -302,775 +234,3 @@ export const EvaluatorForm = (props: {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const InnerEvalConfigForm = (props: {
|
||||
projectId: string;
|
||||
evalTemplate: EvalTemplate;
|
||||
disabled?: boolean;
|
||||
existingEvaluator?: JobConfiguration;
|
||||
onFormSuccess?: () => void;
|
||||
shouldWrapVariables?: boolean;
|
||||
mode?: "create" | "edit";
|
||||
}) => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
disabled: props.disabled,
|
||||
defaultValues: {
|
||||
scoreName:
|
||||
props.existingEvaluator?.scoreName ?? `${props.evalTemplate.name}`,
|
||||
target: props.existingEvaluator?.targetObject ?? "trace",
|
||||
filter: props.existingEvaluator?.filter
|
||||
? z.array(singleFilter).parse(props.existingEvaluator.filter)
|
||||
: [],
|
||||
mapping: props.existingEvaluator?.variableMapping
|
||||
? z
|
||||
.array(variableMapping)
|
||||
.parse(props.existingEvaluator.variableMapping)
|
||||
: z.array(variableMapping).parse(
|
||||
props.evalTemplate
|
||||
? props.evalTemplate.vars.map((v) => ({
|
||||
templateVariable: v,
|
||||
langfuseObject: "trace" as const,
|
||||
selectedColumnId: "input",
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
sampling: props.existingEvaluator?.sampling
|
||||
? props.existingEvaluator.sampling.toNumber()
|
||||
: 1,
|
||||
delay: props.existingEvaluator?.delay
|
||||
? props.existingEvaluator.delay / 1000
|
||||
: 10,
|
||||
timeScope: (props.existingEvaluator?.timeScope ?? ["NEW"]).filter(
|
||||
(option): option is "NEW" | "EXISTING" =>
|
||||
["NEW", "EXISTING"].includes(option),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const traceFilterOptions = api.traces.filterOptions.useQuery(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const datasets = api.datasets.allDatasetMeta.useQuery(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const datasetFilterOptions = useMemo(() => {
|
||||
if (!datasets.data) return undefined;
|
||||
return {
|
||||
datasetId: datasets.data?.map((d) => ({
|
||||
value: d.id,
|
||||
displayValue: d.name,
|
||||
})),
|
||||
};
|
||||
}, [datasets.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.evalTemplate && form.getValues("mapping").length === 0) {
|
||||
form.setValue(
|
||||
"mapping",
|
||||
props.evalTemplate.vars.map((v) => ({
|
||||
templateVariable: v,
|
||||
langfuseObject: "trace" as const,
|
||||
selectedColumnId: "input",
|
||||
})),
|
||||
);
|
||||
form.setValue("scoreName", `${props.evalTemplate.name}`);
|
||||
}
|
||||
}, [form, props.evalTemplate]);
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "mapping",
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const createJobMutation = api.evals.createJob.useMutation({
|
||||
onSuccess: () => utils.models.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
const updateJobMutation = api.evals.updateEvalJob.useMutation({
|
||||
onSuccess: () => utils.evals.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
const [availableVariables, setAvailableVariables] = useState<
|
||||
typeof availableTraceEvalVariables | typeof availableDatasetEvalVariables
|
||||
>(
|
||||
isTraceTarget(props.existingEvaluator?.targetObject ?? "trace")
|
||||
? availableTraceEvalVariables
|
||||
: availableDatasetEvalVariables,
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
capture(
|
||||
props.mode === "edit"
|
||||
? "eval_config:update"
|
||||
: "eval_config:new_form_submit",
|
||||
);
|
||||
|
||||
const validatedFilter = z.array(singleFilter).safeParse(values.filter);
|
||||
|
||||
if (
|
||||
props.existingEvaluator?.timeScope.includes("EXISTING") &&
|
||||
props.mode === "edit" &&
|
||||
!values.timeScope.includes("EXISTING")
|
||||
) {
|
||||
form.setError("timeScope", {
|
||||
type: "manual",
|
||||
message:
|
||||
"The evaluator ran on existing traces already. This cannot be changed anymore.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (form.getValues("timeScope").length === 0) {
|
||||
form.setError("timeScope", {
|
||||
type: "manual",
|
||||
message: "Please select at least one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (validatedFilter.success === false) {
|
||||
form.setError("filter", {
|
||||
type: "manual",
|
||||
message: "Please fill out all filter fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedVarMapping = z
|
||||
.array(variableMapping)
|
||||
.safeParse(values.mapping);
|
||||
|
||||
if (validatedVarMapping.success === false) {
|
||||
console.log(validatedVarMapping.error);
|
||||
form.setError("mapping", {
|
||||
type: "manual",
|
||||
message: "Please fill out all variable mappings",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = values.delay * 1000; // convert to ms
|
||||
const sampling = values.sampling;
|
||||
const mapping = validatedVarMapping.data;
|
||||
const filter = validatedFilter.data;
|
||||
const scoreName = values.scoreName;
|
||||
|
||||
(props.mode === "edit" && props.existingEvaluator
|
||||
? updateJobMutation.mutateAsync({
|
||||
projectId: props.projectId,
|
||||
evalConfigId: props.existingEvaluator.id,
|
||||
config: {
|
||||
delay,
|
||||
filter,
|
||||
variableMapping: mapping,
|
||||
sampling,
|
||||
scoreName,
|
||||
timeScope: values.timeScope,
|
||||
},
|
||||
})
|
||||
: createJobMutation.mutateAsync({
|
||||
projectId: props.projectId,
|
||||
target: values.target,
|
||||
evalTemplateId: props.evalTemplate.id,
|
||||
scoreName,
|
||||
filter,
|
||||
mapping,
|
||||
sampling,
|
||||
delay,
|
||||
timeScope: values.timeScope,
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
form.reset();
|
||||
props.onFormSuccess?.();
|
||||
|
||||
if (props.mode !== "edit") {
|
||||
void router.push(`/project/${props.projectId}/evals`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if ("message" in error && typeof error.message === "string") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
setFormError(error.message as string);
|
||||
return;
|
||||
} else {
|
||||
setFormError(JSON.stringify(error));
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scoreName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Score Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Card className="flex max-w-full flex-col gap-6 overflow-y-auto p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target object</FormLabel>
|
||||
<FormControl>
|
||||
<Tabs
|
||||
defaultValue="trace"
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
const isTrace = isTraceTarget(value);
|
||||
const langfuseObject: LangfuseObject = isTrace
|
||||
? "trace"
|
||||
: "dataset_item";
|
||||
const newMapping = form
|
||||
.getValues("mapping")
|
||||
.map((field) => ({ ...field, langfuseObject }));
|
||||
form.setValue("mapping", newMapping);
|
||||
form.setValue("delay", isTrace ? 10 : 20);
|
||||
setAvailableVariables(
|
||||
isTrace
|
||||
? availableTraceEvalVariables
|
||||
: availableDatasetEvalVariables,
|
||||
);
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="trace"
|
||||
disabled={props.disabled || props.mode === "edit"}
|
||||
>
|
||||
Trace
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dataset"
|
||||
disabled={props.disabled || props.mode === "edit"}
|
||||
>
|
||||
Dataset
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-[300px]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeScope"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Evaluator runs on</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="newObjects"
|
||||
checked={field.value.includes("NEW")}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, "NEW"]
|
||||
: field.value.filter((v) => v !== "NEW");
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="newObjects"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
New{" "}
|
||||
{form.watch("target") === "trace"
|
||||
? "traces"
|
||||
: "dataset items"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="existingObjects"
|
||||
checked={field.value.includes("EXISTING")}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, "EXISTING"]
|
||||
: field.value.filter((v) => v !== "EXISTING");
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={
|
||||
props.disabled ||
|
||||
(props.mode === "edit" &&
|
||||
field.value.includes("EXISTING"))
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="existingObjects"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Existing{" "}
|
||||
{form.watch("target") === "trace"
|
||||
? "traces"
|
||||
: "dataset items"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target filter</FormLabel>
|
||||
{isTraceTarget(form.watch("target")) ? (
|
||||
<>
|
||||
<FormControl>
|
||||
<InlineFilterBuilder
|
||||
columns={tracesTableColsWithOptions(
|
||||
traceFilterOptions.data,
|
||||
evalTraceTableCols,
|
||||
)}
|
||||
filterState={field.value ?? []}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<TimeScopeDescription
|
||||
projectId={props.projectId}
|
||||
timeScope={form.watch("timeScope")}
|
||||
target="trace"
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<InlineFilterBuilder
|
||||
columns={datasetFormFilterColsWithOptions(
|
||||
datasetFilterOptions,
|
||||
evalDatasetFormFilterCols,
|
||||
)}
|
||||
filterState={field.value ?? []}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<TimeScopeDescription
|
||||
projectId={props.projectId}
|
||||
timeScope={form.watch("timeScope")}
|
||||
target="dataset_item"
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mapping"
|
||||
render={() => (
|
||||
<>
|
||||
<FormLabel className="">Variable mapping</FormLabel>
|
||||
<FormControl>
|
||||
Here will some variable mapping be added.
|
||||
</FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex flex-col gap-2",
|
||||
!props.shouldWrapVariables && "lg:flex-row",
|
||||
)}
|
||||
>
|
||||
<JSONView
|
||||
title={"Eval Template"}
|
||||
json={props.evalTemplate.prompt ?? null}
|
||||
className={cn(
|
||||
"min-h-48",
|
||||
!props.shouldWrapVariables && "lg:w-2/3",
|
||||
)}
|
||||
codeClassName="flex-1"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
!props.shouldWrapVariables && "lg:w-1/3",
|
||||
)}
|
||||
>
|
||||
{fields.map((mappingField, index) => (
|
||||
<Card className="flex flex-col gap-2 p-4" key={index}>
|
||||
<div className="text-sm font-semibold">
|
||||
{"{{"}
|
||||
{mappingField.templateVariable}
|
||||
{"}}"}
|
||||
<DocPopup
|
||||
description={
|
||||
"Variable in the template to be replaced with the trace data."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-langfuseObject`}
|
||||
name={`mapping.${index}.langfuseObject`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title="Object"
|
||||
description={
|
||||
"Langfuse object to retrieve the data from."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVariables.map(
|
||||
(evalObject) => (
|
||||
<SelectItem
|
||||
value={evalObject.id}
|
||||
key={evalObject.id}
|
||||
>
|
||||
{evalObject.display}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isTraceOrDatasetObject(
|
||||
form.watch(`mapping.${index}.langfuseObject`),
|
||||
) ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-objectName`}
|
||||
name={`mapping.${index}.objectName`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"Object Name"}
|
||||
description={
|
||||
"Name of the Langfuse object to retrieve the data from."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-selectedColumnId`}
|
||||
name={`mapping.${index}.selectedColumnId`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"Object Variable"}
|
||||
description={
|
||||
"Variable on the Langfuse object to insert into the template."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
defaultValue={field.value ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const availableColumns =
|
||||
availableVariables.find(
|
||||
(evalObject) =>
|
||||
evalObject.id ===
|
||||
form.watch(
|
||||
`mapping.${index}.langfuseObject`,
|
||||
),
|
||||
)?.availableColumns;
|
||||
|
||||
const column = availableColumns?.find(
|
||||
(column) => column.id === value,
|
||||
);
|
||||
|
||||
field.onChange(column?.id);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Object type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVariables
|
||||
.find(
|
||||
(evalObject) =>
|
||||
evalObject.id ===
|
||||
form.watch(
|
||||
`mapping.${index}.langfuseObject`,
|
||||
),
|
||||
)
|
||||
?.availableColumns.map((column) => (
|
||||
<SelectItem
|
||||
value={column.id}
|
||||
key={column.id}
|
||||
>
|
||||
{column.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{fieldHasJsonSelectorOption(
|
||||
form.watch(`mapping.${index}.selectedColumnId`),
|
||||
) ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-jsonSelector`}
|
||||
name={`mapping.${index}.jsonSelector`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"JsonPath"}
|
||||
description={
|
||||
"Optional selection: Use JsonPath syntax to select from a JSON object stored on a trace. If not selected, we will pass the entire object into the prompt."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={props.disabled}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : undefined}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Insert trace data into the prompt template.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="flex flex-col gap-6 p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampling"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sampling</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
disabled={props.disabled}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col">
|
||||
<FormDescription className="flex justify-between">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
</FormDescription>
|
||||
<FormDescription className="mt-1 flex flex-row gap-1">
|
||||
<span>Percentage of traces to evaluate.</span>
|
||||
<span>
|
||||
Currently set to {(field.value * 100).toFixed(0)}%.
|
||||
</span>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time between first Trace/Dataset run event and evaluation
|
||||
execution to ensure all data is available
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!props.disabled ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
|
||||
className="mt-3"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
function VariableMappingDescription(p: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TimeScopeDescription = (props: {
|
||||
projectId: string;
|
||||
timeScope: ("NEW" | "EXISTING")[] | undefined;
|
||||
target: "trace" | "dataset_item" | undefined;
|
||||
}) => {
|
||||
if (!props.timeScope || props.timeScope.length === 0) {
|
||||
return "Select a time scope to run this configuration on.";
|
||||
}
|
||||
|
||||
const globalConfig = api.evals.globalJobConfigs.useQuery({
|
||||
projectId: props.projectId,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
This configuration will run on{" "}
|
||||
{props.timeScope?.includes("NEW") && props.timeScope?.includes("EXISTING")
|
||||
? "all future and existing"
|
||||
: props.timeScope?.includes("NEW")
|
||||
? "all future"
|
||||
: "all existing"}{" "}
|
||||
{props.target === "trace" ? "traces" : "dataset items"} that match these
|
||||
filters.{" "}
|
||||
{globalConfig.data && props.timeScope?.includes("EXISTING")
|
||||
? `We execute the evaluation on up to ${compactNumberFormatter(globalConfig.data)} historic evaluations.`
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { InfoIcon, Loader } from "lucide-react";
|
||||
import {
|
||||
type EvalFormType,
|
||||
isTraceTarget,
|
||||
} from "@/src/ee/features/evals/utils/evaluator-form-utils";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
|
||||
type ExecutionCountTooltipProps = {
|
||||
projectId: string;
|
||||
item: string;
|
||||
filter: EvalFormType["filter"];
|
||||
};
|
||||
|
||||
export const ExecutionCountTooltip = ({
|
||||
projectId,
|
||||
item,
|
||||
filter,
|
||||
}: ExecutionCountTooltipProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const globalConfig = api.evals.globalJobConfigs.useQuery({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const baseAllCountFilter = {
|
||||
projectId,
|
||||
filter,
|
||||
};
|
||||
|
||||
const tracesAllCountFilter = {
|
||||
...baseAllCountFilter,
|
||||
searchQuery: null,
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// utilize `isOpen` to only query if user hovers over tooltip to avoid unnecessary queries
|
||||
const totalCountQuery = api.traces.countAll.useQuery(tracesAllCountFilter, {
|
||||
enabled: isOpen && isTraceTarget(item),
|
||||
});
|
||||
|
||||
const datasetCountQuery = api.datasets.countAllDatasetItems.useQuery(
|
||||
baseAllCountFilter,
|
||||
{
|
||||
enabled: isOpen && !isTraceTarget(item),
|
||||
},
|
||||
);
|
||||
|
||||
const loading = isTraceTarget(item)
|
||||
? totalCountQuery.isLoading
|
||||
: datasetCountQuery.isLoading;
|
||||
|
||||
const totalCount = isTraceTarget(item)
|
||||
? totalCountQuery.data?.totalCount
|
||||
: datasetCountQuery.data?.totalCount;
|
||||
|
||||
return (
|
||||
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
We execute the evaluation on{" "}
|
||||
{loading ? (
|
||||
<Loader className="inline-block h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
compactNumberFormatter(
|
||||
!globalConfig.data ||
|
||||
(totalCount && totalCount < globalConfig.data)
|
||||
? totalCount
|
||||
: globalConfig.data,
|
||||
)
|
||||
)}{" "}
|
||||
{isTraceTarget(item) ? "traces" : "dataset run items"}.
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,801 @@
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import {
|
||||
tracesTableColsWithOptions,
|
||||
evalTraceTableCols,
|
||||
evalDatasetFormFilterCols,
|
||||
singleFilter,
|
||||
type JobConfiguration,
|
||||
availableTraceEvalVariables,
|
||||
datasetFormFilterColsWithOptions,
|
||||
availableDatasetEvalVariables,
|
||||
} from "@langfuse/shared";
|
||||
import * as z from "zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { InlineFilterBuilder } from "@/src/features/filters/components/filter-builder";
|
||||
import { type EvalTemplate, variableMapping } from "@langfuse/shared";
|
||||
import router from "next/router";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import {
|
||||
evalConfigFormSchema,
|
||||
isTraceOrDatasetObject,
|
||||
isTraceTarget,
|
||||
type LangfuseObject,
|
||||
} from "@/src/ee/features/evals/utils/evaluator-form-utils";
|
||||
import { ExecutionCountTooltip } from "@/src/ee/features/evals/components/execution-count-tooltip";
|
||||
import {
|
||||
TimeScopeDescription,
|
||||
VariableMappingDescription,
|
||||
} from "@/src/ee/features/evals/components/eval-form-descriptions";
|
||||
|
||||
const fieldHasJsonSelectorOption = (
|
||||
selectedColumnId: string | undefined | null,
|
||||
): boolean =>
|
||||
selectedColumnId === "input" ||
|
||||
selectedColumnId === "output" ||
|
||||
selectedColumnId === "expected_output";
|
||||
|
||||
export const InnerEvaluatorForm = (props: {
|
||||
projectId: string;
|
||||
evalTemplate: EvalTemplate;
|
||||
disabled?: boolean;
|
||||
existingEvaluator?: JobConfiguration;
|
||||
onFormSuccess?: () => void;
|
||||
shouldWrapVariables?: boolean;
|
||||
mode?: "create" | "edit";
|
||||
}) => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const form = useForm<z.infer<typeof evalConfigFormSchema>>({
|
||||
resolver: zodResolver(evalConfigFormSchema),
|
||||
disabled: props.disabled,
|
||||
defaultValues: {
|
||||
scoreName:
|
||||
props.existingEvaluator?.scoreName ?? `${props.evalTemplate.name}`,
|
||||
target: props.existingEvaluator?.targetObject ?? "trace",
|
||||
filter: props.existingEvaluator?.filter
|
||||
? z.array(singleFilter).parse(props.existingEvaluator.filter)
|
||||
: [],
|
||||
mapping: props.existingEvaluator?.variableMapping
|
||||
? z
|
||||
.array(variableMapping)
|
||||
.parse(props.existingEvaluator.variableMapping)
|
||||
: z.array(variableMapping).parse(
|
||||
props.evalTemplate
|
||||
? props.evalTemplate.vars.map((v) => ({
|
||||
templateVariable: v,
|
||||
langfuseObject: "trace" as const,
|
||||
selectedColumnId: "input",
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
sampling: props.existingEvaluator?.sampling
|
||||
? props.existingEvaluator.sampling.toNumber()
|
||||
: 1,
|
||||
delay: props.existingEvaluator?.delay
|
||||
? props.existingEvaluator.delay / 1000
|
||||
: 10,
|
||||
timeScope: (props.existingEvaluator?.timeScope ?? ["NEW"]).filter(
|
||||
(option): option is "NEW" | "EXISTING" =>
|
||||
["NEW", "EXISTING"].includes(option),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const traceFilterOptions = api.traces.filterOptions.useQuery(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const datasets = api.datasets.allDatasetMeta.useQuery(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
},
|
||||
{
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const datasetFilterOptions = useMemo(() => {
|
||||
if (!datasets.data) return undefined;
|
||||
return {
|
||||
datasetId: datasets.data?.map((d) => ({
|
||||
value: d.id,
|
||||
displayValue: d.name,
|
||||
})),
|
||||
};
|
||||
}, [datasets.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.evalTemplate && form.getValues("mapping").length === 0) {
|
||||
form.setValue(
|
||||
"mapping",
|
||||
props.evalTemplate.vars.map((v) => ({
|
||||
templateVariable: v,
|
||||
langfuseObject: "trace" as const,
|
||||
selectedColumnId: "input",
|
||||
})),
|
||||
);
|
||||
form.setValue("scoreName", `${props.evalTemplate.name}`);
|
||||
}
|
||||
}, [form, props.evalTemplate]);
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "mapping",
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const createJobMutation = api.evals.createJob.useMutation({
|
||||
onSuccess: () => utils.models.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
const updateJobMutation = api.evals.updateEvalJob.useMutation({
|
||||
onSuccess: () => utils.evals.invalidate(),
|
||||
onError: (error) => setFormError(error.message),
|
||||
});
|
||||
const [availableVariables, setAvailableVariables] = useState<
|
||||
typeof availableTraceEvalVariables | typeof availableDatasetEvalVariables
|
||||
>(
|
||||
isTraceTarget(props.existingEvaluator?.targetObject ?? "trace")
|
||||
? availableTraceEvalVariables
|
||||
: availableDatasetEvalVariables,
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof evalConfigFormSchema>) {
|
||||
capture(
|
||||
props.mode === "edit"
|
||||
? "eval_config:update"
|
||||
: "eval_config:new_form_submit",
|
||||
);
|
||||
|
||||
const validatedFilter = z.array(singleFilter).safeParse(values.filter);
|
||||
|
||||
if (
|
||||
props.existingEvaluator?.timeScope.includes("EXISTING") &&
|
||||
props.mode === "edit" &&
|
||||
!values.timeScope.includes("EXISTING")
|
||||
) {
|
||||
form.setError("timeScope", {
|
||||
type: "manual",
|
||||
message:
|
||||
"The evaluator ran on existing traces already. This cannot be changed anymore.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (form.getValues("timeScope").length === 0) {
|
||||
form.setError("timeScope", {
|
||||
type: "manual",
|
||||
message: "Please select at least one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (validatedFilter.success === false) {
|
||||
form.setError("filter", {
|
||||
type: "manual",
|
||||
message: "Please fill out all filter fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedVarMapping = z
|
||||
.array(variableMapping)
|
||||
.safeParse(values.mapping);
|
||||
|
||||
if (validatedVarMapping.success === false) {
|
||||
form.setError("mapping", {
|
||||
type: "manual",
|
||||
message: "Please fill out all variable mappings",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = values.delay * 1000; // convert to ms
|
||||
const sampling = values.sampling;
|
||||
const mapping = validatedVarMapping.data;
|
||||
const filter = validatedFilter.data;
|
||||
const scoreName = values.scoreName;
|
||||
|
||||
(props.mode === "edit" && props.existingEvaluator
|
||||
? updateJobMutation.mutateAsync({
|
||||
projectId: props.projectId,
|
||||
evalConfigId: props.existingEvaluator.id,
|
||||
config: {
|
||||
delay,
|
||||
filter,
|
||||
variableMapping: mapping,
|
||||
sampling,
|
||||
scoreName,
|
||||
timeScope: values.timeScope,
|
||||
},
|
||||
})
|
||||
: createJobMutation.mutateAsync({
|
||||
projectId: props.projectId,
|
||||
target: values.target,
|
||||
evalTemplateId: props.evalTemplate.id,
|
||||
scoreName,
|
||||
filter,
|
||||
mapping,
|
||||
sampling,
|
||||
delay,
|
||||
timeScope: values.timeScope,
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
form.reset();
|
||||
props.onFormSuccess?.();
|
||||
|
||||
if (props.mode !== "edit") {
|
||||
void router.push(`/project/${props.projectId}/evals`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if ("message" in error && typeof error.message === "string") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
setFormError(error.message as string);
|
||||
return;
|
||||
} else {
|
||||
setFormError(JSON.stringify(error));
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scoreName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Score Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Card className="flex max-w-full flex-col gap-6 overflow-y-auto p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target object</FormLabel>
|
||||
<FormControl>
|
||||
<Tabs
|
||||
defaultValue="trace"
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
const isTrace = isTraceTarget(value);
|
||||
const langfuseObject: LangfuseObject = isTrace
|
||||
? "trace"
|
||||
: "dataset_item";
|
||||
const newMapping = form
|
||||
.getValues("mapping")
|
||||
.map((field) => ({ ...field, langfuseObject }));
|
||||
form.setValue("mapping", newMapping);
|
||||
form.setValue("delay", isTrace ? 10 : 20);
|
||||
setAvailableVariables(
|
||||
isTrace
|
||||
? availableTraceEvalVariables
|
||||
: availableDatasetEvalVariables,
|
||||
);
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="trace"
|
||||
disabled={props.disabled || props.mode === "edit"}
|
||||
>
|
||||
Trace
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dataset"
|
||||
disabled={props.disabled || props.mode === "edit"}
|
||||
>
|
||||
Dataset
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-[300px]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeScope"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Evaluator runs on</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="newObjects"
|
||||
checked={field.value.includes("NEW")}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, "NEW"]
|
||||
: field.value.filter((v) => v !== "NEW");
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="newObjects"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
New{" "}
|
||||
{form.watch("target") === "trace"
|
||||
? "traces"
|
||||
: "dataset run items"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id="existingObjects"
|
||||
checked={field.value.includes("EXISTING")}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, "EXISTING"]
|
||||
: field.value.filter((v) => v !== "EXISTING");
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={
|
||||
props.disabled ||
|
||||
(props.mode === "edit" &&
|
||||
field.value.includes("EXISTING"))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="existingObjects"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Existing{" "}
|
||||
{form.watch("target") === "trace"
|
||||
? "traces"
|
||||
: "dataset run items"}
|
||||
</label>
|
||||
{field.value.includes("EXISTING") &&
|
||||
props.mode !== "edit" &&
|
||||
!props.disabled && (
|
||||
<ExecutionCountTooltip
|
||||
projectId={props.projectId}
|
||||
item={form.watch("target")}
|
||||
filter={form.watch("filter")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target filter</FormLabel>
|
||||
{isTraceTarget(form.watch("target")) ? (
|
||||
<>
|
||||
<FormControl>
|
||||
<InlineFilterBuilder
|
||||
columns={tracesTableColsWithOptions(
|
||||
traceFilterOptions.data,
|
||||
evalTraceTableCols,
|
||||
)}
|
||||
filterState={field.value ?? []}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<TimeScopeDescription
|
||||
projectId={props.projectId}
|
||||
timeScope={form.watch("timeScope")}
|
||||
target="trace"
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<InlineFilterBuilder
|
||||
columns={datasetFormFilterColsWithOptions(
|
||||
datasetFilterOptions,
|
||||
evalDatasetFormFilterCols,
|
||||
)}
|
||||
filterState={field.value ?? []}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<TimeScopeDescription
|
||||
projectId={props.projectId}
|
||||
timeScope={form.watch("timeScope")}
|
||||
target="dataset_item"
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mapping"
|
||||
render={() => (
|
||||
<>
|
||||
<FormLabel className="">Variable mapping</FormLabel>
|
||||
<FormControl>
|
||||
Here will some variable mapping be added.
|
||||
</FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex flex-col gap-2",
|
||||
!props.shouldWrapVariables && "lg:flex-row",
|
||||
)}
|
||||
>
|
||||
<JSONView
|
||||
title={"Eval Template"}
|
||||
json={props.evalTemplate.prompt ?? null}
|
||||
className={cn(
|
||||
"min-h-48",
|
||||
!props.shouldWrapVariables && "lg:w-2/3",
|
||||
)}
|
||||
codeClassName="flex-1"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
!props.shouldWrapVariables && "lg:w-1/3",
|
||||
)}
|
||||
>
|
||||
{fields.map((mappingField, index) => (
|
||||
<Card className="flex flex-col gap-2 p-4" key={index}>
|
||||
<div className="text-sm font-semibold">
|
||||
{"{{"}
|
||||
{mappingField.templateVariable}
|
||||
{"}}"}
|
||||
<DocPopup
|
||||
description={
|
||||
"Variable in the template to be replaced with the trace data."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-langfuseObject`}
|
||||
name={`mapping.${index}.langfuseObject`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title="Object"
|
||||
description={
|
||||
"Langfuse object to retrieve the data from."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVariables.map(
|
||||
(evalObject) => (
|
||||
<SelectItem
|
||||
value={evalObject.id}
|
||||
key={evalObject.id}
|
||||
>
|
||||
{evalObject.display}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isTraceOrDatasetObject(
|
||||
form.watch(`mapping.${index}.langfuseObject`),
|
||||
) ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-objectName`}
|
||||
name={`mapping.${index}.objectName`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"Object Name"}
|
||||
description={
|
||||
"Name of the Langfuse object to retrieve the data from."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-selectedColumnId`}
|
||||
name={`mapping.${index}.selectedColumnId`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"Object Variable"}
|
||||
description={
|
||||
"Variable on the Langfuse object to insert into the template."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
defaultValue={field.value ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const availableColumns =
|
||||
availableVariables.find(
|
||||
(evalObject) =>
|
||||
evalObject.id ===
|
||||
form.watch(
|
||||
`mapping.${index}.langfuseObject`,
|
||||
),
|
||||
)?.availableColumns;
|
||||
|
||||
const column = availableColumns?.find(
|
||||
(column) => column.id === value,
|
||||
);
|
||||
|
||||
field.onChange(column?.id);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Object type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVariables
|
||||
.find(
|
||||
(evalObject) =>
|
||||
evalObject.id ===
|
||||
form.watch(
|
||||
`mapping.${index}.langfuseObject`,
|
||||
),
|
||||
)
|
||||
?.availableColumns.map((column) => (
|
||||
<SelectItem
|
||||
value={column.id}
|
||||
key={column.id}
|
||||
>
|
||||
{column.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{fieldHasJsonSelectorOption(
|
||||
form.watch(`mapping.${index}.selectedColumnId`),
|
||||
) ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={`${mappingField.id}-jsonSelector`}
|
||||
name={`mapping.${index}.jsonSelector`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<VariableMappingDescription
|
||||
title={"JsonPath"}
|
||||
description={
|
||||
"Optional selection: Use JsonPath syntax to select from a JSON object stored on a trace. If not selected, we will pass the entire object into the prompt."
|
||||
}
|
||||
href={
|
||||
"https://langfuse.com/docs/scores/model-based-evals"
|
||||
}
|
||||
/>
|
||||
<FormItem className="w-2/3">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={props.disabled}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : undefined}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Insert trace data into the prompt template.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
<Card className="flex flex-col gap-6 p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sampling"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sampling</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
disabled={props.disabled}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-col">
|
||||
<FormDescription className="flex justify-between">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
</FormDescription>
|
||||
<FormDescription className="mt-1 flex flex-row gap-1">
|
||||
<span>Percentage of traces to evaluate.</span>
|
||||
<span>
|
||||
Currently set to {(field.value * 100).toFixed(0)}%.
|
||||
</span>
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time between first Trace/Dataset run event and evaluation
|
||||
execution to ensure all data is available
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!props.disabled ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
|
||||
className="mt-3"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -420,7 +420,7 @@ export const InnerEvalTemplateForm = (props: {
|
||||
<CodeMirrorEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
editable
|
||||
editable={props.isEditing}
|
||||
mode="prompt"
|
||||
minHeight={200}
|
||||
/>
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function EvaluatorsPage() {
|
||||
<ActionButton
|
||||
hasAccess={hasWriteAccess}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
variant="outline"
|
||||
variant="default"
|
||||
onClick={() => capture("eval_config:new_form_open")}
|
||||
href={`/project/${projectId}/evals/new`}
|
||||
limitValue={countsQuery.data?.configActiveCount ?? 0}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function NewEvaluatorPage() {
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
scrollable
|
||||
headerProps={{
|
||||
title: "Create evaluator",
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function NewTemplatesPage() {
|
||||
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
scrollable
|
||||
headerProps={{
|
||||
title: "Create eval template",
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TemplatesPage() {
|
||||
disabled={!hasWriteAccess}
|
||||
onClick={() => capture("eval_templates:new_form_open")}
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="default"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
singleFilter,
|
||||
type langfuseObjects,
|
||||
TimeScopeSchema,
|
||||
} from "@langfuse/shared";
|
||||
import { wipVariableMapping } from "@langfuse/shared";
|
||||
|
||||
export const isTraceTarget = (target: string): boolean => target === "trace";
|
||||
export const isTraceOrDatasetObject = (object: string): boolean =>
|
||||
object === "trace" || object === "dataset_item";
|
||||
|
||||
export const evalConfigFormSchema = z.object({
|
||||
scoreName: z.string(),
|
||||
target: z.string(),
|
||||
filter: z.array(singleFilter).nullable(), // reusing the filter type from the tables
|
||||
mapping: z.array(wipVariableMapping),
|
||||
sampling: z.coerce.number().gt(0).lte(1),
|
||||
delay: z.coerce.number().optional().default(10),
|
||||
timeScope: TimeScopeSchema,
|
||||
});
|
||||
|
||||
export type EvalFormType = z.infer<typeof evalConfigFormSchema>;
|
||||
|
||||
export type LangfuseObject = (typeof langfuseObjects)[number];
|
||||
@@ -79,6 +79,7 @@ import Link from "next/link";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { useExperimentNameValidation } from "@/src/ee/features/experiments/hooks/useExperimentNameValidation";
|
||||
|
||||
const CreateExperimentData = z.object({
|
||||
name: z
|
||||
@@ -311,6 +312,17 @@ export const CreateExperimentsForm = ({
|
||||
|
||||
const archiveEvaluatorMutation = api.evals.updateEvalJob.useMutation();
|
||||
|
||||
const runNamesByDatasetId = api.datasets.baseRunDataByDatasetId.useQuery(
|
||||
{ projectId, datasetId },
|
||||
{ enabled: Boolean(datasetId) },
|
||||
);
|
||||
|
||||
const allExperimentNames = useMemo(() => {
|
||||
return runNamesByDatasetId.data?.map((experiment) => ({
|
||||
value: experiment.name,
|
||||
}));
|
||||
}, [runNamesByDatasetId.data]);
|
||||
|
||||
// Watch model config changes and update form
|
||||
useEffect(() => {
|
||||
form.setValue("modelConfig", {
|
||||
@@ -320,6 +332,12 @@ export const CreateExperimentsForm = ({
|
||||
});
|
||||
}, [modelParams, form]);
|
||||
|
||||
useExperimentNameValidation({
|
||||
currentName: form.watch("name"),
|
||||
allExperimentNames,
|
||||
form,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateExperiment) => {
|
||||
capture("dataset_run:new_form_submit");
|
||||
const experiment = {
|
||||
@@ -895,8 +913,9 @@ export const CreateExperimentsForm = ({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
Boolean(promptId && datasetId) &&
|
||||
!validationResult.data?.isValid
|
||||
(Boolean(promptId && datasetId) &&
|
||||
!validationResult.data?.isValid) ||
|
||||
!!form.formState.errors.name
|
||||
}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface UseExperimentNameValidationProps {
|
||||
currentName: string | undefined;
|
||||
allExperimentNames: { value: string }[] | undefined;
|
||||
form: UseFormReturn<any>;
|
||||
}
|
||||
|
||||
export const useExperimentNameValidation = ({
|
||||
currentName,
|
||||
allExperimentNames,
|
||||
form,
|
||||
}: UseExperimentNameValidationProps) => {
|
||||
useEffect(() => {
|
||||
if (!currentName) {
|
||||
form.clearErrors("name");
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewExperiment = !allExperimentNames
|
||||
?.map((experiment) => experiment.value)
|
||||
.includes(currentName);
|
||||
|
||||
if (!isNewExperiment) {
|
||||
form.setError("name", {
|
||||
message: "Experiment name already exists for this dataset.",
|
||||
});
|
||||
} else {
|
||||
form.clearErrors("name");
|
||||
}
|
||||
}, [currentName, allExperimentNames, form]);
|
||||
};
|
||||
@@ -84,7 +84,7 @@ export const useModelParams = () => {
|
||||
}
|
||||
}, [availableModels, modelParams.model.value]);
|
||||
|
||||
// Update adapter and max temperature when provider changes
|
||||
// Update adapter, max temperature, temperature, max_tokens, top_p when provider changes
|
||||
useEffect(() => {
|
||||
if (selectedProviderApiKey?.adapter) {
|
||||
setModelParams((prev) => ({
|
||||
@@ -93,15 +93,32 @@ export const useModelParams = () => {
|
||||
value: selectedProviderApiKey.adapter,
|
||||
enabled: true,
|
||||
},
|
||||
maxTemperature: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature,
|
||||
maxTemperature: {
|
||||
value: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature.value,
|
||||
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature.enabled,
|
||||
},
|
||||
temperature: {
|
||||
value: Math.min(
|
||||
prev.temperature.value,
|
||||
getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.maxTemperature.value,
|
||||
),
|
||||
enabled: true,
|
||||
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.temperature.enabled,
|
||||
},
|
||||
max_tokens: {
|
||||
value: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.max_tokens.value,
|
||||
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter)
|
||||
.max_tokens.enabled,
|
||||
},
|
||||
top_p: {
|
||||
value: getDefaultAdapterParams(selectedProviderApiKey.adapter).top_p
|
||||
.value,
|
||||
enabled: getDefaultAdapterParams(selectedProviderApiKey.adapter).top_p
|
||||
.enabled,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -194,5 +211,16 @@ function getDefaultAdapterParams(
|
||||
max_tokens: { value: 256, enabled: true },
|
||||
top_p: { value: 1, enabled: true },
|
||||
};
|
||||
case LLMAdapter.Atla:
|
||||
return {
|
||||
adapter: {
|
||||
value: adapter,
|
||||
enabled: true,
|
||||
},
|
||||
temperature: { value: 0, enabled: false },
|
||||
maxTemperature: { value: 1, enabled: false },
|
||||
max_tokens: { value: 4096, enabled: false },
|
||||
top_p: { value: 1, enabled: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user