Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1585bf5e14 | ||
|
|
dec6d6f975 | ||
|
|
36fa7ea15e | ||
|
|
6a4d56a5a9 | ||
|
|
9f0a40949e | ||
|
|
10fdd9e172 | ||
|
|
fc58add111 | ||
|
|
10993838d5 | ||
|
|
148cd29e9f | ||
|
|
7a353e99af | ||
|
|
dc52106feb | ||
|
|
b2f8eb7078 | ||
|
|
026d8e2ba0 | ||
|
|
9a9f0b1743 | ||
|
|
74a510fbde | ||
|
|
c23b226e62 | ||
|
|
2f50b38a68 | ||
|
|
451ae15e00 | ||
|
|
e34d81578b | ||
|
|
214c9c7ed2 | ||
|
|
961b3bfa8d | ||
|
|
a1dd5b22a2 | ||
|
|
49950f9706 | ||
|
|
cfdd0fdb73 | ||
|
|
891e7e9716 | ||
|
|
5b407dad53 | ||
|
|
26e3bf9a44 | ||
|
|
1f02e364e1 | ||
|
|
90dca15d86 | ||
|
|
e3f8dc8bb3 | ||
|
|
585ede0919 | ||
|
|
0db425d120 | ||
|
|
09e33c3059 | ||
|
|
66226011af | ||
|
|
571698ab2e | ||
|
|
312066f735 | ||
|
|
5abeaf8adb | ||
|
|
fac3c732de | ||
|
|
5e2e3bb5fc | ||
|
|
0564df8e51 | ||
|
|
a2801a3be9 | ||
|
|
075eb58ecb | ||
|
|
674d66d179 | ||
|
|
163f2a02ff | ||
|
|
075836f210 | ||
|
|
543b6ee0f2 | ||
|
|
8c8c488e4d | ||
|
|
6a0e0a4221 | ||
|
|
1b01a267df | ||
|
|
69a9146894 | ||
|
|
380403e8ed | ||
|
|
296a6c3ee6 | ||
|
|
62904a9563 | ||
|
|
90247ab64c | ||
|
|
5cf91c60d9 | ||
|
|
559ba6d05d | ||
|
|
f071be69b6 | ||
|
|
52c261b422 | ||
|
|
6f0a43ec65 | ||
|
|
301bd6b569 | ||
|
|
56fd3df2d2 | ||
|
|
db433e2b72 | ||
|
|
e452004a6a | ||
|
|
bce026d8b2 | ||
|
|
fbdf12bfa3 | ||
|
|
78f59bc543 | ||
|
|
ea338ed83d | ||
|
|
8cf67fe329 | ||
|
|
e3b23ea5ec | ||
|
|
7be2791105 | ||
|
|
683aae0069 | ||
|
|
1c8ffc607d | ||
|
|
9c203e8b8a | ||
|
|
c32cccce52 | ||
|
|
f1f8da5b74 | ||
|
|
c23447a624 | ||
|
|
5e2225de46 |
@@ -68,6 +68,7 @@ LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
|
||||
LANGFUSE_USE_AZURE_BLOB=true
|
||||
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK=false
|
||||
|
||||
# Set during docker build of application
|
||||
# Used to disable environment verification at build time
|
||||
|
||||
@@ -82,3 +82,11 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
# Use the following settings to enforce running the new AMTs during the tests
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES="true"
|
||||
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES="true"
|
||||
LANGFUSE_EXPERIMENT_WHITELISTED_PROJECT_IDS="7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS="7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"
|
||||
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT="true"
|
||||
LANGFUSE_EXPERIMENT_SAMPLING_RATE=1
|
||||
@@ -172,6 +172,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# REDIS_HOST=
|
||||
# REDIS_PORT=
|
||||
# REDIS_AUTH=
|
||||
# REDIS_USERNAME=default
|
||||
# REDIS_CONNECTION_STRING=
|
||||
# REDIS_ENABLE_AUTO_PIPELINING=
|
||||
|
||||
|
||||
@@ -114,7 +114,9 @@ jobs:
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Build and run both images from compose
|
||||
run: |
|
||||
docker compose -f docker-compose.build.yml up -d
|
||||
docker compose --progress plain --verbose -f docker-compose.build.yml build --print > /tmp/bake.json
|
||||
docker buildx bake -f /tmp/bake.json
|
||||
docker compose --progress plain -f docker-compose.build.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
- name: Ensure no unhealthy status
|
||||
run: |
|
||||
@@ -490,6 +492,14 @@ jobs:
|
||||
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
run: exit 1
|
||||
working-directory: .
|
||||
- name: Notify Slack
|
||||
uses: ravsamhq/notify-slack-action@v2
|
||||
if: always() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
notify_when: "failure"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
push-docker-image:
|
||||
needs: all-ci-passed
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
/generated/typescript-server
|
||||
/generated
|
||||
|
||||
# openapi spec that is copied during build
|
||||
/public/openapi*.yml
|
||||
|
||||
+1
-1
@@ -19,6 +19,7 @@ services:
|
||||
ports:
|
||||
- 127.0.0.1:3030:3030
|
||||
environment: &langfuse-worker-env
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres # CHANGEME
|
||||
SALT: "mysalt" # CHANGEME
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # CHANGEME: generate via `openssl rand -hex 32`
|
||||
@@ -73,7 +74,6 @@ services:
|
||||
- 3000:3000
|
||||
environment:
|
||||
<<: *langfuse-worker-env
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: mysecret # CHANGEME
|
||||
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
|
||||
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
|
||||
|
||||
@@ -105,6 +105,28 @@ service:
|
||||
docs: The unique identifier of the annotation queue item
|
||||
response: DeleteAnnotationQueueItemResponse
|
||||
|
||||
createQueueAssignment:
|
||||
docs: Create an assignment for a user to an annotation queue
|
||||
method: POST
|
||||
path: /annotation-queues/{queueId}/assignments
|
||||
path-parameters:
|
||||
queueId:
|
||||
type: string
|
||||
docs: The unique identifier of the annotation queue
|
||||
request: AnnotationQueueAssignmentRequest
|
||||
response: CreateAnnotationQueueAssignmentResponse
|
||||
|
||||
deleteQueueAssignment:
|
||||
docs: Delete an assignment for a user to an annotation queue
|
||||
method: DELETE
|
||||
path: /annotation-queues/{queueId}/assignments
|
||||
path-parameters:
|
||||
queueId:
|
||||
type: string
|
||||
docs: The unique identifier of the annotation queue
|
||||
request: AnnotationQueueAssignmentRequest
|
||||
response: DeleteAnnotationQueueAssignmentResponse
|
||||
|
||||
types:
|
||||
AnnotationQueueStatus:
|
||||
enum:
|
||||
@@ -163,3 +185,17 @@ types:
|
||||
properties:
|
||||
success: boolean
|
||||
message: string
|
||||
|
||||
AnnotationQueueAssignmentRequest:
|
||||
properties:
|
||||
userId: string
|
||||
|
||||
DeleteAnnotationQueueAssignmentResponse:
|
||||
properties:
|
||||
success: boolean
|
||||
|
||||
CreateAnnotationQueueAssignmentResponse:
|
||||
properties:
|
||||
userId: string
|
||||
queueId: string
|
||||
projectId: string
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
list:
|
||||
method: GET
|
||||
docs: Get all LLM connections in a project
|
||||
path: /llm-connections
|
||||
request:
|
||||
name: GetLlmConnectionsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedLlmConnections
|
||||
upsert:
|
||||
method: PUT
|
||||
docs: Create or update an LLM connection. The connection is upserted on provider.
|
||||
path: /llm-connections
|
||||
request: UpsertLlmConnectionRequest
|
||||
response: LlmConnection
|
||||
|
||||
types:
|
||||
LlmConnection:
|
||||
docs: LLM API connection configuration (secrets excluded)
|
||||
properties:
|
||||
id: string
|
||||
provider:
|
||||
type: string
|
||||
docs: Provider name (e.g., 'openai', 'my-gateway'). Must be unique in project, used for upserting.
|
||||
adapter:
|
||||
type: string
|
||||
docs: The adapter used to interface with the LLM
|
||||
displaySecretKey:
|
||||
type: string
|
||||
docs: Masked version of the secret key for display purposes
|
||||
baseURL:
|
||||
type: optional<string>
|
||||
docs: Custom base URL for the LLM API
|
||||
customModels:
|
||||
type: list<string>
|
||||
docs: List of custom model names available for this connection
|
||||
withDefaultModels:
|
||||
type: boolean
|
||||
docs: Whether to include default models for this adapter
|
||||
extraHeaderKeys:
|
||||
type: list<string>
|
||||
docs: Keys of extra headers sent with requests (values excluded for security)
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
PaginatedLlmConnections:
|
||||
properties:
|
||||
data: list<LlmConnection>
|
||||
meta: pagination.MetaResponse
|
||||
|
||||
UpsertLlmConnectionRequest:
|
||||
docs: Request to create or update an LLM connection (upsert)
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
docs: Provider name (e.g., 'openai', 'my-gateway'). Must be unique in project, used for upserting.
|
||||
adapter:
|
||||
type: LlmAdapter
|
||||
docs: The adapter used to interface with the LLM
|
||||
secretKey:
|
||||
type: string
|
||||
docs: Secret key for the LLM API.
|
||||
baseURL:
|
||||
type: optional<string>
|
||||
docs: Custom base URL for the LLM API
|
||||
customModels:
|
||||
type: optional<list<string>>
|
||||
docs: List of custom model names
|
||||
withDefaultModels:
|
||||
type: optional<boolean>
|
||||
docs: Whether to include default models. Default is true.
|
||||
extraHeaders:
|
||||
type: optional<map<string, string>>
|
||||
docs: Extra headers to send with requests
|
||||
|
||||
LlmAdapter:
|
||||
enum:
|
||||
- value: anthropic
|
||||
name: Anthropic
|
||||
- value: openai
|
||||
name: OpenAI
|
||||
- value: azure
|
||||
name: Azure
|
||||
- value: bedrock
|
||||
name: Bedrock
|
||||
- value: google-vertex-ai
|
||||
name: GoogleVertexAI
|
||||
- value: google-ai-studio
|
||||
name: GoogleAIStudio
|
||||
@@ -1,3 +1,4 @@
|
||||
# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json
|
||||
default-group: local
|
||||
groups:
|
||||
local:
|
||||
@@ -7,6 +8,7 @@ groups:
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/api
|
||||
|
||||
- name: fernapi/fern-python-sdk
|
||||
version: 2.16.0
|
||||
output:
|
||||
@@ -19,35 +21,32 @@ groups:
|
||||
pydantic_config:
|
||||
require_optional_fields: false
|
||||
use_str_enums: false
|
||||
# - name: fernapi/fern-java-sdk
|
||||
# version: 2.20.1
|
||||
# output:
|
||||
# location: local-file-system
|
||||
# path: ../../../../langfuse-java/src/main/java/com/langfuse/client/
|
||||
# config:
|
||||
# client-class-name: LangfuseClient
|
||||
|
||||
# - name: fernapi/fern-java-sdk
|
||||
# version: 2.20.1
|
||||
# output:
|
||||
# location: local-file-system
|
||||
# path: ../../../../langfuse-java/src/main/java/com/langfuse/client/
|
||||
# config:
|
||||
# client-class-name: LangfuseClient
|
||||
|
||||
- name: fernapi/fern-typescript-node-sdk
|
||||
version: 2.6.1
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../generated/typescript
|
||||
config:
|
||||
namespaceExport: LangfuseAPI
|
||||
outputSourceFiles: true
|
||||
skipResponseValidation: true
|
||||
fetchSupport: native
|
||||
formDataSupport: Node18
|
||||
fileResponseType: binary-response
|
||||
streamType: web
|
||||
omitFernHeaders: true
|
||||
|
||||
- name: fernapi/fern-postman
|
||||
version: 0.0.45
|
||||
output:
|
||||
location: local-file-system
|
||||
path: ../../../web/public/generated/postman
|
||||
# published:
|
||||
# generators:
|
||||
# - name: fernapi/fern-python-sdk
|
||||
# version: 0.3.7
|
||||
# output:
|
||||
# location: pypi
|
||||
# url: pypi.buildwithfern.com
|
||||
# package-name: finto-fern-langfuse
|
||||
# config:
|
||||
# namespaceExport: Langfuse
|
||||
# allowCustomFetcher: true
|
||||
# - name: fernapi/fern-typescript-node-sdk
|
||||
# version: 0.7.1
|
||||
# output:
|
||||
# location: npm
|
||||
# url: npm.buildwithfern.com
|
||||
# package-name: "@finto-fern/langfuse-node"
|
||||
# config:
|
||||
# namespaceExport: Langfuse
|
||||
# allowCustomFetcher: true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
python/
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.89.0",
|
||||
"version": "3.95.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE dataset_run_items ON CLUSTER default;
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE dataset_run_items ON CLUSTER default (
|
||||
-- primary identifiers
|
||||
`id` String,
|
||||
`project_id` String,
|
||||
`dataset_run_id` String,
|
||||
`dataset_item_id` String,
|
||||
`dataset_id` String,
|
||||
`trace_id` String,
|
||||
`observation_id` Nullable(String),
|
||||
|
||||
-- error field
|
||||
`error` Nullable(String),
|
||||
|
||||
-- timestamps
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
|
||||
-- denormalized immutable dataset run fields
|
||||
`dataset_run_name` String,
|
||||
`dataset_run_description` Nullable(String),
|
||||
`dataset_run_metadata` Map(LowCardinality(String), String),
|
||||
`dataset_run_created_at` DateTime64(3),
|
||||
|
||||
-- denormalized dataset item fields (mutable, but snapshots are relevant)
|
||||
`dataset_item_input` Nullable(String) CODEC(ZSTD(3)), -- json
|
||||
`dataset_item_expected_output` Nullable(String) CODEC(ZSTD(3)), -- json
|
||||
`dataset_item_metadata` Map(LowCardinality(String), String),
|
||||
|
||||
-- clickhouse engine fields
|
||||
`event_ts` DateTime64(3),
|
||||
`is_deleted` UInt8,
|
||||
|
||||
-- For dataset item lookups
|
||||
INDEX idx_dataset_item dataset_item_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
) ENGINE = ReplacingMergeTree(event_ts, is_deleted)
|
||||
ORDER BY (project_id, dataset_id, dataset_run_id, id);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- Drop materialized views first
|
||||
DROP VIEW IF EXISTS traces_30d_amt_mv ON CLUSTER default;
|
||||
DROP VIEW IF EXISTS traces_7d_amt_mv ON CLUSTER default;
|
||||
DROP VIEW IF EXISTS traces_all_amt_mv ON CLUSTER default;
|
||||
|
||||
-- Drop AMT tables
|
||||
DROP TABLE IF EXISTS traces_30d_amt ON CLUSTER default;
|
||||
DROP TABLE IF EXISTS traces_7d_amt ON CLUSTER default;
|
||||
DROP TABLE IF EXISTS traces_all_amt ON CLUSTER default;
|
||||
|
||||
-- Drop the Null table
|
||||
DROP TABLE IF EXISTS traces_null ON CLUSTER default;
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
-- Create a Null table that serves as a trigger for all materialized views.
|
||||
-- We use a Null engine here to avoid storing intermediate results and save on storage.
|
||||
CREATE TABLE traces_null ON CLUSTER default
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`start_time` DateTime64(3),
|
||||
`end_time` Nullable(DateTime64(3)),
|
||||
`name` Nullable(String),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` Map(LowCardinality(String), String),
|
||||
`user_id` Nullable(String),
|
||||
`session_id` Nullable(String),
|
||||
`environment` String,
|
||||
`tags` Array(String),
|
||||
`version` Nullable(String),
|
||||
`release` Nullable(String),
|
||||
|
||||
-- UI properties - We make them nullable to prevent absent values being interpreted as overwrites.
|
||||
`bookmarked` Nullable(Bool),
|
||||
`public` Nullable(Bool),
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
`observation_ids` Array(String),
|
||||
`score_ids` Array(String),
|
||||
`cost_details` Map(String, Decimal64(12)),
|
||||
`usage_details` Map(String, UInt64),
|
||||
-- TODO: Do we want to aggregate/collect `levels` seen within the trace?
|
||||
|
||||
-- Input/Output
|
||||
`input` String,
|
||||
`output` String,
|
||||
|
||||
`created_at` DateTime64(3),
|
||||
`updated_at` DateTime64(3),
|
||||
`event_ts` DateTime64(3)
|
||||
) Engine = Null();
|
||||
|
||||
-- Create the all AMT
|
||||
CREATE TABLE traces_all_amt ON CLUSTER default
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_trace_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id);
|
||||
|
||||
-- Create materialized view for all_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_all_amt_mv ON CLUSTER default TO traces_all_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
|
||||
-- Create the 7-day TTL AMT
|
||||
CREATE TABLE traces_7d_amt ON CLUSTER default
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id)
|
||||
TTL toDate(start_time) + INTERVAL 7 DAY;
|
||||
|
||||
-- Create materialized view for 7d_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_7d_amt_mv ON CLUSTER default TO traces_7d_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
|
||||
-- Create the 30-day TTL AMT
|
||||
CREATE TABLE traces_30d_amt ON CLUSTER default
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id)
|
||||
TTL toDate(start_time) + INTERVAL 30 DAY;
|
||||
|
||||
-- Create materialized view for 30d_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_30d_amt_mv ON CLUSTER default TO traces_30d_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations -- DO NOT USE
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE dataset_run_items;
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE dataset_run_items (
|
||||
-- primary identifiers
|
||||
`id` String,
|
||||
`project_id` String,
|
||||
`dataset_run_id` String,
|
||||
`dataset_item_id` String,
|
||||
`dataset_id` String,
|
||||
`trace_id` String,
|
||||
`observation_id` Nullable(String),
|
||||
|
||||
-- error field
|
||||
`error` Nullable(String),
|
||||
|
||||
-- timestamps
|
||||
`created_at` DateTime64(3) DEFAULT now(),
|
||||
`updated_at` DateTime64(3) DEFAULT now(),
|
||||
|
||||
-- denormalized immutable dataset run fields
|
||||
`dataset_run_name` String,
|
||||
`dataset_run_description` Nullable(String),
|
||||
`dataset_run_metadata` Map(LowCardinality(String), String),
|
||||
`dataset_run_created_at` DateTime64(3),
|
||||
|
||||
-- denormalized dataset item fields (mutable, but snapshots are relevant)
|
||||
`dataset_item_input` Nullable(String) CODEC(ZSTD(3)), -- json
|
||||
`dataset_item_expected_output` Nullable(String) CODEC(ZSTD(3)), -- json
|
||||
`dataset_item_metadata` Map(LowCardinality(String), String),
|
||||
|
||||
-- clickhouse engine fields
|
||||
`event_ts` DateTime64(3),
|
||||
`is_deleted` UInt8,
|
||||
|
||||
-- For dataset item lookups
|
||||
INDEX idx_dataset_item dataset_item_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
) ENGINE = ReplacingMergeTree(event_ts, is_deleted)
|
||||
ORDER BY (project_id, dataset_id, dataset_run_id, id);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
-- Drop materialized views first
|
||||
DROP VIEW IF EXISTS traces_30d_amt_mv;
|
||||
DROP VIEW IF EXISTS traces_7d_amt_mv;
|
||||
DROP VIEW IF EXISTS traces_all_amt_mv;
|
||||
|
||||
-- Drop AMT tables
|
||||
DROP TABLE IF EXISTS traces_30d_amt;
|
||||
DROP TABLE IF EXISTS traces_7d_amt;
|
||||
DROP TABLE IF EXISTS traces_all_amt;
|
||||
|
||||
-- Drop the Null table
|
||||
DROP TABLE IF EXISTS traces_null;
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
-- Create a Null table that serves as a trigger for all materialized views.
|
||||
-- We use a Null engine here to avoid storing intermediate results and save on storage.
|
||||
CREATE TABLE traces_null
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`start_time` DateTime64(3),
|
||||
`end_time` Nullable(DateTime64(3)),
|
||||
`name` Nullable(String),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` Map(LowCardinality(String), String),
|
||||
`user_id` Nullable(String),
|
||||
`session_id` Nullable(String),
|
||||
`environment` String,
|
||||
`tags` Array(String),
|
||||
`version` Nullable(String),
|
||||
`release` Nullable(String),
|
||||
|
||||
-- UI properties - We make them nullable to prevent absent values being interpreted as overwrites.
|
||||
`bookmarked` Nullable(Bool),
|
||||
`public` Nullable(Bool),
|
||||
|
||||
-- Aggregations
|
||||
`observation_ids` Array(String),
|
||||
`score_ids` Array(String),
|
||||
`cost_details` Map(String, Decimal64(12)),
|
||||
`usage_details` Map(String, UInt64),
|
||||
-- TODO: Do we want to aggregate/collect `levels` seen within the trace?
|
||||
|
||||
-- Input/Output
|
||||
`input` String,
|
||||
`output` String,
|
||||
|
||||
`created_at` DateTime64(3),
|
||||
`updated_at` DateTime64(3),
|
||||
`event_ts` DateTime64(3)
|
||||
) Engine = Null();
|
||||
|
||||
-- Create the all AMT
|
||||
CREATE TABLE traces_all_amt
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_trace_id id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id);
|
||||
|
||||
-- Create materialized view for all_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_all_amt_mv TO traces_all_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
|
||||
-- Create the 7-day TTL AMT
|
||||
CREATE TABLE traces_7d_amt
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id)
|
||||
TTL toDate(start_time) + INTERVAL 7 DAY;
|
||||
|
||||
-- Create materialized view for 7d_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_7d_amt_mv TO traces_7d_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
|
||||
-- Create the 30-day TTL AMT
|
||||
CREATE TABLE traces_30d_amt
|
||||
(
|
||||
-- Identifiers
|
||||
`project_id` String,
|
||||
`id` String,
|
||||
`timestamp` SimpleAggregateFunction(min, DateTime64(3)), -- Backward compatibility: redundant with start_time
|
||||
`start_time` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`end_time` SimpleAggregateFunction(max, Nullable(DateTime64(3))),
|
||||
`name` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- Metadata properties
|
||||
`metadata` SimpleAggregateFunction(maxMap, Map(String, String)),
|
||||
`user_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`session_id` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`environment` SimpleAggregateFunction(anyLast, String),
|
||||
`tags` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`version` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
`release` SimpleAggregateFunction(anyLast, Nullable(String)),
|
||||
|
||||
-- UI properties
|
||||
`bookmarked` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
`public` AggregateFunction(argMax, Nullable(Bool), DateTime64(3)),
|
||||
|
||||
-- Aggregations
|
||||
`observation_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`score_ids` SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
|
||||
`cost_details` SimpleAggregateFunction(sumMap, Map(String, Decimal(38, 12))),
|
||||
`usage_details` SimpleAggregateFunction(sumMap, Map(String, UInt64)),
|
||||
|
||||
-- Input/Output -> prefer correctness via argMax
|
||||
`input` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
`output` AggregateFunction(argMax, String, DateTime64(3)) CODEC (ZSTD(3)),
|
||||
|
||||
`created_at` SimpleAggregateFunction(min, DateTime64(3)),
|
||||
`updated_at` SimpleAggregateFunction(max, DateTime64(3)),
|
||||
|
||||
-- Indexes
|
||||
INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_name name TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_version version TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_release release TYPE bloom_filter(0.001) GRANULARITY 1,
|
||||
INDEX idx_tags tags TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
) Engine = AggregatingMergeTree()
|
||||
ORDER BY (project_id, id)
|
||||
TTL toDate(start_time) + INTERVAL 30 DAY;
|
||||
|
||||
-- Create materialized view for 30d_amt
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS traces_30d_amt_mv TO traces_30d_amt AS
|
||||
SELECT
|
||||
-- Identifiers
|
||||
tn.project_id as project_id,
|
||||
tn.id as id,
|
||||
min(tn.start_time) as timestamp, -- Backward compatibility: redundant with start_time
|
||||
min(tn.start_time) as start_time,
|
||||
max(coalesce(tn.end_time, tn.start_time)) as end_time,
|
||||
anyLast(tn.name) as name,
|
||||
|
||||
-- Metadata properties
|
||||
maxMap(tn.metadata) as metadata,
|
||||
anyLast(tn.user_id) as user_id,
|
||||
anyLast(tn.session_id) as session_id,
|
||||
anyLast(tn.environment) as environment,
|
||||
groupUniqArrayArray(tn.tags) as tags,
|
||||
anyLast(tn.version) as version,
|
||||
anyLast(tn.release) as release,
|
||||
|
||||
-- UI properties
|
||||
argMaxState(tn.bookmarked, if(tn.bookmarked is not null, tn.event_ts, toDateTime64(0, 3))) as bookmarked,
|
||||
argMaxState(tn.public, if(tn.public is not null, tn.event_ts, toDateTime64(0, 3))) as public,
|
||||
|
||||
-- Aggregations
|
||||
groupUniqArrayArray(tn.observation_ids) as observation_ids,
|
||||
groupUniqArrayArray(tn.score_ids) as score_ids,
|
||||
sumMap(tn.cost_details) as cost_details,
|
||||
sumMap(tn.usage_details) as usage_details,
|
||||
|
||||
-- Input/Output
|
||||
argMaxState(tn.input, if(tn.input <> '', tn.event_ts, toDateTime64(0, 3))) as input,
|
||||
argMaxState(tn.output, if(tn.output <> '', tn.event_ts, toDateTime64(0, 3))) as output,
|
||||
|
||||
min(tn.created_at) as created_at,
|
||||
max(tn.updated_at) as updated_at
|
||||
FROM traces_null tn
|
||||
GROUP BY project_id, id;
|
||||
@@ -185,6 +185,14 @@ export type AnnotationQueue = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type AnnotationQueueAssignment = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
queue_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type AnnotationQueueItem = {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
@@ -628,6 +636,15 @@ export type OrganizationMembership = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type PendingDeletion = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
object: string;
|
||||
object_id: string;
|
||||
is_deleted: Generated<boolean>;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type PosthogIntegration = {
|
||||
project_id: string;
|
||||
encrypted_posthog_api_key: string;
|
||||
@@ -723,7 +740,7 @@ export type SlackIntegration = {
|
||||
bot_token: string;
|
||||
bot_user_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Timestamp;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type SsoConfig = {
|
||||
domain: string;
|
||||
@@ -795,6 +812,7 @@ export type VerificationToken = {
|
||||
export type DB = {
|
||||
Account: Account;
|
||||
actions: Action;
|
||||
annotation_queue_assignments: AnnotationQueueAssignment;
|
||||
annotation_queue_items: AnnotationQueueItem;
|
||||
annotation_queues: AnnotationQueue;
|
||||
api_keys: ApiKey;
|
||||
@@ -827,6 +845,7 @@ export type DB = {
|
||||
observations: LegacyPrismaObservation;
|
||||
organization_memberships: OrganizationMembership;
|
||||
organizations: Organization;
|
||||
pending_deletions: PendingDeletion;
|
||||
posthog_integrations: PosthogIntegration;
|
||||
prices: Price;
|
||||
project_memberships: ProjectMembership;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
INSERT INTO background_migrations (id, name, script, args)
|
||||
VALUES ('8d47f91b-3e5c-4a26-9f85-c12d6e4b9a3d', '20250731_1001_migrate_dataset_run_items_pg_to_ch', 'migrateDatasetRunItemsFromPostgresToClickhouse', '{}');
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "pending_deletions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"object" TEXT NOT NULL,
|
||||
"object_id" TEXT NOT NULL,
|
||||
"is_deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "pending_deletions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pending_deletions_project_id_object_is_deleted_idx" ON "pending_deletions"("project_id", "object", "is_deleted");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pending_deletions_object_id_object_idx" ON "pending_deletions"("object_id", "object");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pending_deletions" ADD CONSTRAINT "pending_deletions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "annotation_queue_assignments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"queue_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "annotation_queue_assignments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "annotation_queue_assignments_project_id_queue_id_key" ON "annotation_queue_assignments"("project_id", "queue_id", "user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "annotation_queue_assignments" ADD CONSTRAINT "annotation_queue_assignments_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "annotation_queues"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -65,29 +65,30 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
annotatedLockedItem AnnotationQueueItem[] @relation("LockedByUser")
|
||||
annotatedCompletedItem AnnotationQueueItem[] @relation("AnnotatorUser")
|
||||
dashboardWidgetsCreated DashboardWidget[] @relation("CreatedByUser")
|
||||
dashboardWidgetsUpdated DashboardWidget[] @relation("UpdatedByUser")
|
||||
dashboardCreated Dashboard[] @relation("CreatedByUser")
|
||||
dashboardUpdated Dashboard[] @relation("UpdatedByUser")
|
||||
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
|
||||
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
image String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
organizationMemberships OrganizationMembership[]
|
||||
projectMemberships ProjectMembership[]
|
||||
invitations MembershipInvitation[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
featureFlags String[] @default([]) @map("feature_flags")
|
||||
annotatedLockedItem AnnotationQueueItem[] @relation("LockedByUser")
|
||||
annotatedCompletedItem AnnotationQueueItem[] @relation("AnnotatorUser")
|
||||
dashboardWidgetsCreated DashboardWidget[] @relation("CreatedByUser")
|
||||
dashboardWidgetsUpdated DashboardWidget[] @relation("UpdatedByUser")
|
||||
dashboardCreated Dashboard[] @relation("CreatedByUser")
|
||||
dashboardUpdated Dashboard[] @relation("UpdatedByUser")
|
||||
tableViewPresetCreated TableViewPreset[] @relation("CreatedByUser")
|
||||
tableViewPresetUpdated TableViewPreset[] @relation("UpdatedByUser")
|
||||
annotationQueueAssignment AnnotationQueueAssignment[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -117,53 +118,55 @@ 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")
|
||||
metadata Json?
|
||||
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[]
|
||||
LlmSchema LlmSchema[]
|
||||
LlmTool LlmTool[]
|
||||
PromptProtectedLabels PromptProtectedLabels[]
|
||||
Dashboard Dashboard[]
|
||||
DashboardWidget DashboardWidget[]
|
||||
TableViewPreset TableViewPreset[]
|
||||
actions Action[]
|
||||
triggers Trigger[]
|
||||
automationExecutions AutomationExecution[]
|
||||
Automation Automation[]
|
||||
DefaultLlmModel DefaultLlmModel[]
|
||||
Price Price[]
|
||||
SlackIntegration SlackIntegration?
|
||||
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")
|
||||
metadata Json?
|
||||
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[]
|
||||
LlmSchema LlmSchema[]
|
||||
LlmTool LlmTool[]
|
||||
PromptProtectedLabels PromptProtectedLabels[]
|
||||
Dashboard Dashboard[]
|
||||
DashboardWidget DashboardWidget[]
|
||||
TableViewPreset TableViewPreset[]
|
||||
actions Action[]
|
||||
triggers Trigger[]
|
||||
automationExecutions AutomationExecution[]
|
||||
Automation Automation[]
|
||||
DefaultLlmModel DefaultLlmModel[]
|
||||
Price Price[]
|
||||
SlackIntegration SlackIntegration?
|
||||
PendingDeletion PendingDeletion[]
|
||||
AnnotationQueueAssignment AnnotationQueueAssignment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
@@ -490,15 +493,16 @@ enum ScoreDataType {
|
||||
}
|
||||
|
||||
model AnnotationQueue {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
scoreConfigIds String[] @default([]) @map("score_config_ids")
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
annotationQueueItem AnnotationQueueItem[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
scoreConfigIds String[] @default([]) @map("score_config_ids")
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
annotationQueueItem AnnotationQueueItem[]
|
||||
annotationQueueAssignment AnnotationQueueAssignment[]
|
||||
|
||||
@@unique([projectId, name])
|
||||
@@index([id, projectId])
|
||||
@@ -543,6 +547,21 @@ enum AnnotationQueueObjectType {
|
||||
SESSION
|
||||
}
|
||||
|
||||
model AnnotationQueueAssignment {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
queueId String @map("queue_id")
|
||||
queue AnnotationQueue @relation(fields: [queueId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([projectId, queueId, userId])
|
||||
@@map("annotation_queue_assignments")
|
||||
}
|
||||
|
||||
model CronJobs {
|
||||
name String @id
|
||||
lastRun DateTime? @map("last_run")
|
||||
@@ -1355,8 +1374,26 @@ model SlackIntegration {
|
||||
botUserId String @map("bot_user_id") // Bot user ID for workspace
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([teamId])
|
||||
@@map("slack_integrations")
|
||||
}
|
||||
|
||||
// Pending Deletions: Tracks objects (like traces) that are scheduled for batch deletion
|
||||
model PendingDeletion {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
object String @map("object") // e.g., "trace", "observation", etc.
|
||||
objectId String @map("object_id") // The ID of the object to be deleted
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@index([projectId, object, isDeleted])
|
||||
@@index([objectId, object])
|
||||
@@map("pending_deletions")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const EnvSchema = z.object({
|
||||
.default(6379)
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_USERNAME: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_KEY_PREFIX: z.string().nullish(),
|
||||
REDIS_TLS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
@@ -49,6 +50,10 @@ const EnvSchema = z.object({
|
||||
.nonnegative()
|
||||
.default(15_000),
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce.number().positive().default(1),
|
||||
LANGFUSE_TRACE_DELETE_DELAY_MS: z.coerce
|
||||
.number()
|
||||
.nonnegative()
|
||||
.default(5_000),
|
||||
SALT: z.string().optional(), // used by components imported by web package
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
@@ -87,6 +92,9 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
|
||||
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_AZURE_SKIP_CONTAINER_CHECK: z
|
||||
.enum(["true", "false"])
|
||||
.default("true"),
|
||||
LANGFUSE_USE_GOOGLE_CLOUD_STORAGE: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
@@ -140,6 +148,12 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -176,6 +190,18 @@ const EnvSchema = z.object({
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_STATE_SECRET: z.string().optional(),
|
||||
|
||||
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1_000),
|
||||
|
||||
LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(600_000), // 10 minutes
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -91,4 +91,11 @@ export const CreateQueueData = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CreateQueueWithAssignmentsData = CreateQueueData.extend({
|
||||
newAssignmentUserIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type CreateQueue = z.infer<typeof CreateQueueData>;
|
||||
export type CreateQueueWithAssignments = z.infer<
|
||||
typeof CreateQueueWithAssignmentsData
|
||||
>;
|
||||
|
||||
@@ -15,6 +15,7 @@ const ActionIdSchema = z.enum([
|
||||
"trace-delete",
|
||||
"trace-add-to-annotation-queue",
|
||||
"session-add-to-annotation-queue",
|
||||
"observation-add-to-annotation-queue",
|
||||
]);
|
||||
|
||||
export type ActionId = z.infer<typeof ActionIdSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { instrumentAsync } from "../instrumentation";
|
||||
import { instrumentAsync, recordDistribution } from "../instrumentation";
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
@@ -19,12 +19,17 @@ const executionWrapper = async <T, Y>(
|
||||
return [res, duration];
|
||||
};
|
||||
|
||||
/**
|
||||
* Measures the execution time of two functions and returns the result based on the experiment configuration.
|
||||
* This is used to compare the execution of AggregatingMergeTrees with the existing ReplacingMergeTree execution.
|
||||
*/
|
||||
export const measureAndReturn = async <T, Y>(args: {
|
||||
operationName: string;
|
||||
projectId: string;
|
||||
input: T;
|
||||
existingExecution: (input: T) => Promise<Y>; // eslint-disable-line no-unused-vars
|
||||
newExecution: (input: T) => Promise<Y>; // eslint-disable-line no-unused-vars
|
||||
minStartTime?: Date;
|
||||
}): Promise<Y> => {
|
||||
return instrumentAsync(
|
||||
{
|
||||
@@ -32,14 +37,34 @@ export const measureAndReturn = async <T, Y>(args: {
|
||||
spanKind: opentelemetry.SpanKind.CLIENT,
|
||||
},
|
||||
async (currentSpan) => {
|
||||
const { input, existingExecution, newExecution } = args;
|
||||
const { input, existingExecution, newExecution, minStartTime } = args;
|
||||
|
||||
if (
|
||||
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES !==
|
||||
"true"
|
||||
) {
|
||||
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "disabled");
|
||||
return existingExecution(input);
|
||||
|
||||
// Check for short-term new result experiment
|
||||
if (
|
||||
env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM === "true" &&
|
||||
minStartTime
|
||||
) {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
if (minStartTime >= thirtyDaysAgo) {
|
||||
currentSpan.setAttribute(
|
||||
`langfuse.experiment.amts.short-term`,
|
||||
"true",
|
||||
);
|
||||
return newExecution(input);
|
||||
}
|
||||
}
|
||||
|
||||
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
|
||||
? newExecution(input)
|
||||
: existingExecution(input);
|
||||
}
|
||||
|
||||
// If not whitelisted, apply sampling logic
|
||||
@@ -68,6 +93,14 @@ export const measureAndReturn = async <T, Y>(args: {
|
||||
durationDifference,
|
||||
);
|
||||
|
||||
recordDistribution(
|
||||
"langfuse.experiment.amts.duration_difference_distribution",
|
||||
durationDifference,
|
||||
{
|
||||
operation: args.operationName,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
env.LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS.some(
|
||||
(p) => p === args.projectId,
|
||||
@@ -83,6 +116,23 @@ export const measureAndReturn = async <T, Y>(args: {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for short-term new result experiment
|
||||
if (
|
||||
env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT_SHORT_TERM === "true" &&
|
||||
minStartTime
|
||||
) {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
if (minStartTime >= thirtyDaysAgo) {
|
||||
currentSpan.setAttribute(
|
||||
`langfuse.experiment.amts.short-term`,
|
||||
"true",
|
||||
);
|
||||
return newResult;
|
||||
}
|
||||
}
|
||||
|
||||
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
|
||||
? newResult
|
||||
: existingResult;
|
||||
|
||||
@@ -58,6 +58,7 @@ export * from "./logger";
|
||||
export * from "./headerPropagation";
|
||||
export * from "./queries";
|
||||
export * from "./repositories";
|
||||
export * from "./utils/rendering";
|
||||
export * from "./redis/evalExecutionQueue";
|
||||
export * from "./services/sessions-ui-table-service";
|
||||
export * from "./services/datasets-ui-table-service";
|
||||
@@ -78,3 +79,4 @@ export * from "./dataset-run-items/addToDeleteQueue";
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
export * from "./utils/headerUtils";
|
||||
export * from "./traceDeletionProcessor";
|
||||
|
||||
@@ -316,22 +316,6 @@ 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;
|
||||
|
||||
@@ -236,7 +236,6 @@ export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
export enum LLMAdapter {
|
||||
Anthropic = "anthropic",
|
||||
OpenAI = "openai",
|
||||
Atla = "atla",
|
||||
Azure = "azure",
|
||||
Bedrock = "bedrock",
|
||||
VertexAI = "google-vertex-ai",
|
||||
@@ -332,6 +331,7 @@ export type OpenAIModel = (typeof openAIModels)[number];
|
||||
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
|
||||
export const anthropicModels = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
@@ -378,8 +378,6 @@ 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 = {
|
||||
@@ -389,7 +387,6 @@ export const supportedModels = {
|
||||
[LLMAdapter.GoogleAIStudio]: googleAIStudioModels,
|
||||
[LLMAdapter.Azure]: [],
|
||||
[LLMAdapter.Bedrock]: [],
|
||||
[LLMAdapter.Atla]: atlaModels,
|
||||
} as const;
|
||||
|
||||
export type LLMFunctionCall = {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "../filterToPrisma";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { orderByToPrismaSql } from "../orderByToPrisma";
|
||||
import { sessionsViewCols } from "../../tableDefinitions";
|
||||
|
||||
const GetSessionTableSQLParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
orderBy: orderBy,
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
});
|
||||
type GetSessionTableSQLParams = z.infer<typeof GetSessionTableSQLParamsSchema>;
|
||||
|
||||
export const createSessionsAllQuery = (
|
||||
select: Prisma.Sql,
|
||||
params: GetSessionTableSQLParams,
|
||||
options?: {
|
||||
ignoreOrderBy?: boolean; // used by session.metrics and session.all.totalCount
|
||||
sessionIdList?: string[]; // used by session.metrics
|
||||
},
|
||||
): Prisma.Sql => {
|
||||
const { projectId, filter, orderBy, page, limit } =
|
||||
GetSessionTableSQLParamsSchema.parse(params);
|
||||
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
filter ?? [],
|
||||
sessionsViewCols,
|
||||
"sessions",
|
||||
);
|
||||
const orderByCondition = orderByToPrismaSql(orderBy, sessionsViewCols);
|
||||
|
||||
const sessionIdFilter = options?.sessionIdList
|
||||
? Prisma.sql`AND s.id IN (${Prisma.join(options?.sessionIdList)})`
|
||||
: Prisma.sql``;
|
||||
|
||||
const sql = Prisma.sql`
|
||||
SELECT
|
||||
${select}
|
||||
FROM
|
||||
trace_sessions AS s
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t. "timestamp") AS "max_timestamp",
|
||||
MIN(t. "timestamp") AS "min_timestamp",
|
||||
array_agg(t.id) AS "traceIds",
|
||||
array_agg(DISTINCT t.user_id) AS "userIds",
|
||||
count(t.id)::int AS "countTraces",
|
||||
array_agg(DISTINCT u.tag) AS "tags"
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT UNNEST(t.tags) AS tag) AS u ON TRUE
|
||||
WHERE
|
||||
t.project_id = ${projectId}
|
||||
AND t.session_id = s.id
|
||||
GROUP BY
|
||||
t.session_id) AS t ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX(o. "end_time"), MAX(o. "start_time"), t. "max_timestamp")) - EXTRACT(EPOCH FROM COALESCE(MIN(o. "start_time"), t. "min_timestamp"))::double precision AS "sessionDuration",
|
||||
SUM(COALESCE(o. "calculated_input_cost", 0)) AS "inputCost",
|
||||
SUM(COALESCE(o. "calculated_output_cost", 0)) AS "outputCost",
|
||||
SUM(COALESCE(o. "calculated_total_cost", 0)) AS "totalCost",
|
||||
SUM(o.prompt_tokens) AS "promptTokens",
|
||||
SUM(o.completion_tokens) AS "completionTokens",
|
||||
SUM(o.total_tokens) AS "totalTokens"
|
||||
FROM
|
||||
observations_view o
|
||||
WHERE
|
||||
o.project_id = ${projectId}
|
||||
AND o.trace_id = ANY (t. "traceIds")) AS o ON TRUE
|
||||
WHERE
|
||||
s. "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${sessionIdFilter}
|
||||
${options?.ignoreOrderBy ? Prisma.sql`` : orderByCondition}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit}
|
||||
`;
|
||||
|
||||
return sql;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export { createSessionsAllQuery } from "./createSessionsAllQuery";
|
||||
export {
|
||||
type FullObservations,
|
||||
type FullObservationsWithScores,
|
||||
|
||||
@@ -125,6 +125,15 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
|
||||
targetId: z.string().optional(),
|
||||
type: z.enum(BatchActionType),
|
||||
}),
|
||||
z.object({
|
||||
actionId: z.literal("observation-add-to-annotation-queue"),
|
||||
projectId: z.string(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.enum(BatchTableNames),
|
||||
cutoffCreatedAt: z.date(),
|
||||
targetId: z.string().optional(),
|
||||
type: z.enum(BatchActionType),
|
||||
}),
|
||||
z.object({
|
||||
actionId: z.literal("eval-create"),
|
||||
targetObject: z.enum(["trace", "dataset"]),
|
||||
|
||||
@@ -77,6 +77,7 @@ const createRedisClusterInstance = (
|
||||
callback(null, address);
|
||||
},
|
||||
redisOptions: {
|
||||
username: env.REDIS_USERNAME || undefined,
|
||||
password: env.REDIS_AUTH || undefined,
|
||||
...defaultRedisOptions,
|
||||
...additionalOptions,
|
||||
@@ -129,6 +130,7 @@ export const createNewRedisInstance = (
|
||||
? new Redis({
|
||||
host: String(env.REDIS_HOST),
|
||||
port: Number(env.REDIS_PORT),
|
||||
username: env.REDIS_USERNAME || undefined,
|
||||
password: String(env.REDIS_AUTH),
|
||||
...defaultRedisOptions,
|
||||
...additionalOptions,
|
||||
|
||||
@@ -36,7 +36,7 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
|
||||
export async function upsertClickhouse<
|
||||
T extends Record<string, unknown>,
|
||||
>(opts: {
|
||||
table: "scores" | "traces" | "observations";
|
||||
table: "scores" | "traces" | "observations" | "traces_null";
|
||||
records: T[];
|
||||
eventBodyMapper: (body: T) => Record<string, unknown>; // eslint-disable-line no-unused-vars
|
||||
tags?: Record<string, string>;
|
||||
|
||||
@@ -8,21 +8,69 @@ import {
|
||||
orderByToClickhouseSql,
|
||||
StringFilter,
|
||||
} from "../queries";
|
||||
import { queryClickhouse } from "./clickhouse";
|
||||
import {
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
} from "./clickhouse";
|
||||
import { convertDatasetRunItemClickhouseToDomain } from "./dataset-run-items-converters";
|
||||
import { DatasetRunItemRecordReadType } from "./definitions";
|
||||
import { env } from "../../env";
|
||||
import { commandClickhouse } from "./clickhouse";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
type DatasetRunItemsTableQuery = {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
filter: FilterState;
|
||||
orderBy?: OrderByState | OrderByState[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type DatasetRunsMetricsTableQuery = {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type DatasetRunsMetrics = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
createdAt: Date;
|
||||
datasetId: string;
|
||||
countRunItems: number;
|
||||
avgTotalCost: Decimal;
|
||||
avgLatency: number;
|
||||
};
|
||||
|
||||
type DatasetRunsMetricsRecordType = {
|
||||
dataset_run_id: string;
|
||||
project_id: string;
|
||||
dataset_run_created_at: string;
|
||||
dataset_id: string;
|
||||
count_run_items: number;
|
||||
avg_latency_seconds: number;
|
||||
avg_total_cost: number;
|
||||
};
|
||||
|
||||
const convertDatasetRunsMetricsRecord = (
|
||||
record: DatasetRunsMetricsRecordType,
|
||||
): DatasetRunsMetrics => {
|
||||
return {
|
||||
id: record.dataset_run_id,
|
||||
projectId: record.project_id,
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(record.dataset_run_created_at),
|
||||
datasetId: record.dataset_id,
|
||||
countRunItems: record.count_run_items,
|
||||
avgTotalCost: record.avg_total_cost
|
||||
? new Decimal(record.avg_total_cost)
|
||||
: new Decimal(0),
|
||||
avgLatency: record.avg_latency_seconds ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getProjectDatasetIdDefaultFilter = (
|
||||
projectId: string,
|
||||
datasetId: string,
|
||||
@@ -45,6 +93,148 @@ const getProjectDatasetIdDefaultFilter = (
|
||||
};
|
||||
};
|
||||
|
||||
const getDatasetRunsTableInternal = async <T>(
|
||||
opts: DatasetRunsMetricsTableQuery & {
|
||||
tags: Record<string, string>;
|
||||
},
|
||||
): Promise<Array<T>> => {
|
||||
const { projectId, datasetId, orderBy, limit, offset } = opts;
|
||||
|
||||
const { datasetRunItemsFilter } = getProjectDatasetIdDefaultFilter(
|
||||
projectId,
|
||||
datasetId,
|
||||
);
|
||||
const appliedFilter = datasetRunItemsFilter.apply();
|
||||
|
||||
// Build ORDER BY array - conditionally add event_ts DESC for rows
|
||||
const orderByArray: OrderByState[] = [];
|
||||
|
||||
// Add user ordering if provided
|
||||
if (orderBy) {
|
||||
orderByArray.push(orderBy);
|
||||
}
|
||||
|
||||
const orderByClause = orderByToClickhouseSql(
|
||||
orderByArray,
|
||||
datasetRunItemsTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
const query = `
|
||||
WITH observations_filtered AS (
|
||||
SELECT
|
||||
o.id,
|
||||
o.trace_id,
|
||||
o.project_id,
|
||||
o.start_time,
|
||||
o.end_time,
|
||||
o.total_cost
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
AND o.start_time >= (
|
||||
SELECT min(dri.dataset_run_created_at) - INTERVAL 1 DAY
|
||||
FROM dataset_run_items dri
|
||||
WHERE dri.project_id = {projectId: String}
|
||||
AND dri.dataset_id = {datasetId: String}
|
||||
)
|
||||
AND o.start_time <= (
|
||||
SELECT max(dri.dataset_run_created_at) + INTERVAL 1 DAY
|
||||
FROM dataset_run_items dri
|
||||
WHERE dri.project_id = {projectId: String}
|
||||
AND dri.dataset_id = {datasetId: String}
|
||||
)
|
||||
),
|
||||
traces_aggregated AS (
|
||||
SELECT
|
||||
of.trace_id,
|
||||
of.project_id,
|
||||
dateDiff('millisecond', min(of.start_time), max(of.end_time)) as latency_ms,
|
||||
sum(of.total_cost) as total_cost
|
||||
FROM observations_filtered of
|
||||
JOIN dataset_run_items dri ON dri.trace_id = of.trace_id
|
||||
AND dri.project_id = of.project_id
|
||||
AND dri.observation_id IS NULL -- Only for trace-level dataset run items
|
||||
WHERE dri.dataset_id = {datasetId: String}
|
||||
GROUP BY of.trace_id, of.project_id
|
||||
),
|
||||
observations_direct AS (
|
||||
SELECT
|
||||
dri.observation_id,
|
||||
dri.project_id,
|
||||
dri.trace_id,
|
||||
of.total_cost,
|
||||
dateDiff('millisecond', of.start_time, of.end_time) as latency_ms
|
||||
FROM dataset_run_items dri
|
||||
JOIN observations_filtered of ON dri.observation_id = of.id
|
||||
AND dri.project_id = of.project_id
|
||||
AND dri.trace_id = of.trace_id
|
||||
WHERE dri.dataset_id = {datasetId: String}
|
||||
AND dri.observation_id IS NOT NULL -- Only for observation-level dataset run items
|
||||
)
|
||||
SELECT DISTINCT
|
||||
dri.dataset_run_id as dataset_run_id,
|
||||
dri.project_id as project_id,
|
||||
dri.dataset_id as dataset_id,
|
||||
dri.dataset_run_created_at as dataset_run_created_at,
|
||||
count(DISTINCT dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id) as count_run_items,
|
||||
|
||||
-- Latency metrics (priority: observation > trace)
|
||||
AVG(CASE
|
||||
WHEN dri.observation_id IS NOT NULL AND od.latency_ms IS NOT NULL
|
||||
THEN od.latency_ms / 1000.0
|
||||
ELSE COALESCE(ta.latency_ms / 1000.0, 0)
|
||||
END) as avg_latency_seconds,
|
||||
|
||||
-- Cost metrics (priority: observation > trace)
|
||||
AVG(CASE
|
||||
WHEN dri.observation_id IS NOT NULL AND od.total_cost IS NOT NULL
|
||||
THEN od.total_cost
|
||||
ELSE COALESCE(ta.total_cost, 0)
|
||||
END) as avg_total_cost
|
||||
FROM dataset_run_items dri
|
||||
LEFT JOIN traces_aggregated ta
|
||||
ON dri.trace_id = ta.trace_id
|
||||
AND dri.project_id = ta.project_id
|
||||
LEFT JOIN observations_direct od
|
||||
ON dri.observation_id = od.observation_id
|
||||
AND dri.project_id = od.project_id
|
||||
AND dri.trace_id = od.trace_id
|
||||
WHERE ${appliedFilter.query}
|
||||
GROUP BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_run_created_at
|
||||
ORDER BY dri.dataset_run_created_at DESC
|
||||
${orderByClause}
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
datasetId,
|
||||
...appliedFilter.params,
|
||||
},
|
||||
tags: {
|
||||
...(opts.tags ?? {}),
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
projectId,
|
||||
datasetId,
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getDatasetRunsTableMetricsCh = async (
|
||||
opts: DatasetRunsMetricsTableQuery,
|
||||
): Promise<DatasetRunsMetrics[]> => {
|
||||
// First get the metrics (latency, cost, counts)
|
||||
const rows = await getDatasetRunsTableInternal<DatasetRunsMetricsRecordType>({
|
||||
...opts,
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
return rows.map(convertDatasetRunsMetricsRecord);
|
||||
};
|
||||
|
||||
const getDatasetRunItemsTableInternal = async <T>(
|
||||
opts: DatasetRunItemsTableQuery & {
|
||||
select: "count" | "rows";
|
||||
@@ -104,7 +294,11 @@ const getDatasetRunItemsTableInternal = async <T>(
|
||||
|
||||
// Add user ordering if provided
|
||||
if (orderBy) {
|
||||
orderByArray.push(orderBy);
|
||||
if (Array.isArray(orderBy)) {
|
||||
orderByArray.push(...orderBy);
|
||||
} else {
|
||||
orderByArray.push(orderBy);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event_ts DESC for row queries (for deduplication)
|
||||
@@ -170,6 +364,7 @@ export const getDatasetRunItemsCountByDatasetIdCh = async (
|
||||
|
||||
return Number(rows[0]?.count);
|
||||
};
|
||||
|
||||
export const deleteDatasetRunItemsByProjectId = async ({
|
||||
projectId,
|
||||
}: {
|
||||
|
||||
@@ -122,7 +122,7 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
|
||||
});
|
||||
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
|
||||
|
||||
export const traceMtRecordInsertSchema = z.object({
|
||||
export const traceNullRecordInsertSchema = z.object({
|
||||
// Identifiers
|
||||
project_id: z.string(),
|
||||
id: z.string(),
|
||||
@@ -157,7 +157,9 @@ export const traceMtRecordInsertSchema = z.object({
|
||||
updated_at: z.number(),
|
||||
event_ts: z.number(),
|
||||
});
|
||||
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
|
||||
export type TraceNullRecordInsertType = z.infer<
|
||||
typeof traceNullRecordInsertSchema
|
||||
>;
|
||||
|
||||
export const scoreRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -495,9 +497,9 @@ export const convertPostgresScoreToInsert = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertTraceToTraceMt = (
|
||||
export const convertTraceToTraceNull = (
|
||||
traceRecord: TraceRecordInsertType,
|
||||
): TraceMtRecordInsertType => {
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: traceRecord.project_id,
|
||||
@@ -535,13 +537,13 @@ export const convertTraceToTraceMt = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertObservationToTraceMt = (
|
||||
export const convertObservationToTraceNull = (
|
||||
observationRecord: ObservationRecordInsertType,
|
||||
): TraceMtRecordInsertType => {
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: observationRecord.project_id,
|
||||
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
|
||||
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
|
||||
id: observationRecord.trace_id || "",
|
||||
start_time: observationRecord.start_time,
|
||||
end_time: observationRecord.end_time || null,
|
||||
@@ -579,13 +581,13 @@ export const convertObservationToTraceMt = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertScoreToTraceMt = (
|
||||
export const convertScoreToTraceNull = (
|
||||
scoreRecord: ScoreRecordInsertType,
|
||||
): TraceMtRecordInsertType => {
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: scoreRecord.project_id,
|
||||
// Use trace_id as the id in traces_mt. Always set given the conditions around calling the function
|
||||
// Use trace_id as the id in traces_null. Always set given the conditions around calling the function
|
||||
id: scoreRecord.trace_id || "",
|
||||
start_time: scoreRecord.timestamp,
|
||||
end_time: null, // scores don't have end_time
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
observationsTableUiColumnDefinitions,
|
||||
} from "../../tableDefinitions";
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { getTracesByIds } from "./traces";
|
||||
import { getTimeframesTracesAMT, getTracesByIds } from "./traces";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { convertObservation } from "./observations_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
@@ -36,6 +37,7 @@ import { TracingSearchType } from "../../interfaces/search";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { ObservationType } from "../../domain";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
import { DEFAULT_RENDERING_PROPS, RenderingProps } from "../utils/rendering";
|
||||
|
||||
/**
|
||||
* Checks if observation exists in clickhouse.
|
||||
@@ -288,7 +290,7 @@ export const getObservationForTraceIdByName = async (
|
||||
},
|
||||
});
|
||||
|
||||
return records.map(convertObservation);
|
||||
return records.map((record) => convertObservation(record));
|
||||
};
|
||||
|
||||
export const getObservationById = async ({
|
||||
@@ -298,6 +300,7 @@ export const getObservationById = async ({
|
||||
startTime,
|
||||
type,
|
||||
traceId,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -305,6 +308,7 @@ export const getObservationById = async ({
|
||||
startTime?: Date;
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
renderingProps?: RenderingProps;
|
||||
}) => {
|
||||
const records = await getObservationByIdInternal({
|
||||
id,
|
||||
@@ -313,8 +317,11 @@ export const getObservationById = async ({
|
||||
startTime,
|
||||
type,
|
||||
traceId,
|
||||
renderingProps,
|
||||
});
|
||||
const mapped = records.map(convertObservation);
|
||||
const mapped = records.map((record) =>
|
||||
convertObservation(record, renderingProps),
|
||||
);
|
||||
|
||||
mapped.forEach((observation) => {
|
||||
recordDistribution(
|
||||
@@ -384,7 +391,7 @@ export const getObservationsById = async (
|
||||
query,
|
||||
params: { ids, projectId },
|
||||
});
|
||||
return records.map(convertObservation);
|
||||
return records.map((record) => convertObservation(record));
|
||||
};
|
||||
|
||||
const getObservationByIdInternal = async ({
|
||||
@@ -394,6 +401,7 @@ const getObservationByIdInternal = async ({
|
||||
startTime,
|
||||
type,
|
||||
traceId,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -401,6 +409,7 @@ const getObservationByIdInternal = async ({
|
||||
startTime?: Date;
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
renderingProps?: RenderingProps;
|
||||
}) => {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -417,7 +426,7 @@ const getObservationByIdInternal = async ({
|
||||
level,
|
||||
status_message,
|
||||
version,
|
||||
${fetchWithInputOutput ? "input, output," : ""}
|
||||
${fetchWithInputOutput ? (renderingProps.truncated ? `left(input, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT}) as input, left(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT}) as output,` : "input, output,") : ""}
|
||||
provided_model_name,
|
||||
internal_model_id,
|
||||
model_parameters,
|
||||
@@ -612,12 +621,14 @@ const getObservationsTableInternal = async <T>(
|
||||
} = opts;
|
||||
|
||||
const selectString = selectIOAndMetadata
|
||||
? `
|
||||
${select},
|
||||
${selectIOAndMetadata ? `o.input, o.output, o.metadata` : ""}
|
||||
`
|
||||
? `${select}, o.input, o.output, o.metadata`
|
||||
: select;
|
||||
|
||||
const timeFilter = filter.find(
|
||||
(f) =>
|
||||
f.column === "Start Time" && (f.operator === ">=" || f.operator === ">"),
|
||||
);
|
||||
|
||||
const scoresFilter = new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "scores",
|
||||
@@ -627,33 +638,22 @@ const getObservationsTableInternal = async <T>(
|
||||
}),
|
||||
]);
|
||||
|
||||
const timeFilter = opts.filter.find(
|
||||
(f) =>
|
||||
f.column === "Start Time" && (f.operator === ">=" || f.operator === ">"),
|
||||
);
|
||||
|
||||
// query optimisation: joining traces onto observations is expensive. Hence, only join if the UI table contains filters on traces.
|
||||
const traceTableFilter = opts.filter.filter(
|
||||
(f) =>
|
||||
observationsTableTraceUiColumnDefinitions
|
||||
.map((c) => c.uiTableId)
|
||||
.includes(f.column) ||
|
||||
observationsTableTraceUiColumnDefinitions
|
||||
.map((c) => c.uiTableName)
|
||||
.includes(f.column),
|
||||
);
|
||||
|
||||
const hasScoresFilter = filter.some((f) =>
|
||||
f.column.toLowerCase().includes("scores"),
|
||||
);
|
||||
|
||||
const orderByTraces = opts.orderBy
|
||||
? observationsTableTraceUiColumnDefinitions
|
||||
.map((c) => c.uiTableId)
|
||||
.includes(opts.orderBy.column) ||
|
||||
observationsTableTraceUiColumnDefinitions
|
||||
.map((c) => c.uiTableName)
|
||||
.includes(opts.orderBy.column)
|
||||
// query optimisation: joining traces onto observations is expensive. Hence, only join if the UI table contains filters on traces.
|
||||
const traceTableFilter = filter.filter((f) =>
|
||||
observationsTableTraceUiColumnDefinitions.some(
|
||||
(c) => c.uiTableId === f.column || c.uiTableName === f.column,
|
||||
),
|
||||
);
|
||||
|
||||
const orderByTraces = orderBy
|
||||
? observationsTableTraceUiColumnDefinitions.some(
|
||||
(c) =>
|
||||
c.uiTableId === orderBy.column || c.uiTableName === orderBy.column,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
timeFilter
|
||||
@@ -759,7 +759,7 @@ const getObservationsTableInternal = async <T>(
|
||||
SELECT
|
||||
${selectString}
|
||||
FROM observations o
|
||||
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN traces t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
|
||||
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN __TRACE_TABLE__ t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
|
||||
${hasScoresFilter ? `LEFT JOIN scores_agg AS s ON s.trace_id = o.trace_id and s.observation_id = o.id` : ""}
|
||||
WHERE ${appliedObservationsFilter.query}
|
||||
|
||||
@@ -769,30 +769,52 @@ const getObservationsTableInternal = async <T>(
|
||||
${opts.select === "rows" ? "LIMIT 1 BY o.id, o.project_id" : ""}
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query,
|
||||
params: {
|
||||
...appliedScoresFilter.params,
|
||||
...appliedObservationsFilter.params,
|
||||
...(timeFilter
|
||||
? {
|
||||
tracesTimestampFilter: convertDateToClickhouseDateTime(
|
||||
timeFilter.value as Date,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...search.params,
|
||||
return measureAndReturn({
|
||||
operationName: "getObservationsTableInternal",
|
||||
projectId,
|
||||
minStartTime: (timeFilter?.value as Date) || undefined,
|
||||
input: {
|
||||
params: {
|
||||
...appliedScoresFilter.params,
|
||||
...appliedObservationsFilter.params,
|
||||
...(timeFilter
|
||||
? {
|
||||
tracesTimestampFilter: convertDateToClickhouseDateTime(
|
||||
timeFilter.value as Date,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...search.params,
|
||||
},
|
||||
tags: {
|
||||
...(opts.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "observation",
|
||||
projectId,
|
||||
kind: opts.select,
|
||||
operation_name: "getObservationsTableInternal",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
...(opts.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "observation",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(
|
||||
(timeFilter?.value as Date) || undefined,
|
||||
);
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getObservationsGroupedByModel = async (
|
||||
@@ -1473,6 +1495,9 @@ export const getObservationsForBlobStorageExport = function (
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
|
||||
return records;
|
||||
@@ -1531,7 +1556,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 300_000, // 5 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
clickhouse_settings: {
|
||||
join_algorithm: "grace_hash",
|
||||
grace_hash_join_initial_buckets: "32",
|
||||
@@ -1545,6 +1570,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
timestamp: record.start_time,
|
||||
langfuse_generation_name: record.name,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_trace_id: record.trace_id,
|
||||
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
|
||||
langfuse_id: record.id,
|
||||
langfuse_cost_usd: record.total_cost,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { ObservationRecordReadType } from "./definitions";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import {
|
||||
Observation,
|
||||
ObservationLevelType,
|
||||
ObservationType,
|
||||
} from "../../domain";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import {
|
||||
RenderingProps,
|
||||
DEFAULT_RENDERING_PROPS,
|
||||
applyInputOutputRendering,
|
||||
} from "../utils/rendering";
|
||||
|
||||
export const convertObservation = (
|
||||
record: ObservationRecordReadType,
|
||||
renderingProps: RenderingProps = DEFAULT_RENDERING_PROPS,
|
||||
): Observation => {
|
||||
const reducedCostDetails = reduceUsageOrCostDetails(record.cost_details);
|
||||
const reducedUsageDetails = reduceUsageOrCostDetails(record.usage_details);
|
||||
@@ -30,10 +35,8 @@ export const convertObservation = (
|
||||
level: record.level as ObservationLevelType,
|
||||
statusMessage: record.status_message ?? null,
|
||||
version: record.version ?? null,
|
||||
input: record.input ? (parseJsonPrioritised(record.input) ?? null) : null,
|
||||
output: record.output
|
||||
? (parseJsonPrioritised(record.output) ?? null)
|
||||
: null,
|
||||
input: applyInputOutputRendering(record.input, renderingProps),
|
||||
output: applyInputOutputRendering(record.output, renderingProps),
|
||||
modelParameters: record.model_parameters
|
||||
? (JSON.parse(record.model_parameters) ?? null)
|
||||
: null,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
import { prisma } from "../../db";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
|
||||
export const searchExistingAnnotationScore = async (
|
||||
projectId: string,
|
||||
@@ -284,6 +285,53 @@ export const getScoresForDatasetRuns = async <
|
||||
return rows.map(convertToScore);
|
||||
};
|
||||
|
||||
export const getTraceScoresForDatasetRuns = async (
|
||||
projectId: string,
|
||||
datasetRunIds: string[],
|
||||
): Promise<Array<{ dataset_run_id: string } & any>> => {
|
||||
if (datasetRunIds.length === 0) return [];
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
s.* EXCEPT (metadata),
|
||||
length(mapKeys(s.metadata)) > 0 AS has_metadata,
|
||||
dri.dataset_run_id as run_id
|
||||
FROM dataset_run_items dri
|
||||
JOIN scores s FINAL ON dri.trace_id = s.trace_id
|
||||
AND dri.project_id = s.project_id
|
||||
WHERE dri.project_id = {projectId: String}
|
||||
AND dri.dataset_run_id IN {datasetRunIds: Array(String)}
|
||||
AND s.project_id = {projectId: String}
|
||||
ORDER BY s.event_ts DESC
|
||||
LIMIT 1 BY s.id, s.project_id, dri.dataset_run_id
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<
|
||||
Omit<ScoreRecordReadType, "metadata"> & {
|
||||
has_metadata: 0 | 1;
|
||||
run_id: string;
|
||||
}
|
||||
>({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
datasetRunIds,
|
||||
},
|
||||
tags: {
|
||||
feature: "dataset-run-items",
|
||||
type: "trace-scores",
|
||||
kind: "list",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
...convertToScore({ ...row, metadata: {} }),
|
||||
datasetRunId: row.run_id,
|
||||
hasMetadata: !!row.has_metadata,
|
||||
}));
|
||||
};
|
||||
|
||||
// Used in multiple places, including the public API, hence the non-default exclusion of metadata via excludeMetadata flag
|
||||
export const getScoresForTraces = async <
|
||||
ExcludeMetadata extends boolean,
|
||||
@@ -895,31 +943,49 @@ const getScoresUiGeneric = async <T>(props: {
|
||||
SELECT
|
||||
${select}
|
||||
FROM scores s final
|
||||
${performTracesJoin ? "LEFT JOIN traces t ON s.trace_id = t.id AND t.project_id = s.project_id" : ""}
|
||||
${performTracesJoin ? "LEFT JOIN __TRACE_TABLE__ t ON s.trace_id = t.id AND t.project_id = s.project_id" : ""}
|
||||
WHERE s.project_id = {projectId: String}
|
||||
${scoresFilterRes?.query ? `AND ${scoresFilterRes.query}` : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, scoresTableUiColumnDefinitions)}
|
||||
${limit !== undefined && offset !== undefined ? `limit {limit: Int32} offset {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId: projectId,
|
||||
...(scoresFilterRes ? scoresFilterRes.params : {}),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
return measureAndReturn({
|
||||
operationName: "getScoresUiGeneric",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId: projectId,
|
||||
...(scoresFilterRes ? scoresFilterRes.params : {}),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
projectId,
|
||||
select: props.select,
|
||||
operation_name: "getScoresUiGeneric",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces_all_amt"),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const getScoreNames = async (
|
||||
@@ -1320,6 +1386,9 @@ export const getScoresForBlobStorageExport = function (
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
|
||||
return records;
|
||||
@@ -1339,6 +1408,7 @@ export const getScoresForPostHog = async function* (
|
||||
s.data_type as data_type,
|
||||
s.comment as comment,
|
||||
s.environment as environment,
|
||||
t.id as trace_id,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
t.user_id as trace_user_id,
|
||||
@@ -1370,7 +1440,7 @@ export const getScoresForPostHog = async function* (
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 300_000, // 5 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
clickhouse_settings: {
|
||||
join_algorithm: "grace_hash",
|
||||
grace_hash_join_initial_buckets: "32",
|
||||
@@ -1389,6 +1459,7 @@ export const getScoresForPostHog = async function* (
|
||||
langfuse_score_string_value: record.string_value,
|
||||
langfuse_score_data_type: record.data_type,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_trace_id: record.trace_id,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
langfuse_project_id: projectId,
|
||||
|
||||
@@ -15,10 +15,13 @@ import {
|
||||
FilterList,
|
||||
StringFilter,
|
||||
} from "../queries/clickhouse-sql/clickhouse-filter";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { TraceRecordReadType, convertTraceToTraceNull } from "./definitions";
|
||||
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
|
||||
import { UiColumnMappings } from "../../tableDefinitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import {
|
||||
clickhouseClient,
|
||||
convertDateToClickhouseDateTime,
|
||||
} from "../clickhouse/client";
|
||||
import { convertClickhouseToDomain } from "./traces_converters";
|
||||
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
|
||||
import {
|
||||
@@ -29,6 +32,7 @@ import { env } from "../../env";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { DEFAULT_RENDERING_PROPS, RenderingProps } from "../utils/rendering";
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
enum TracesAMTs {
|
||||
@@ -143,6 +147,7 @@ export const checkTraceExists = async ({
|
||||
return measureAndReturn({
|
||||
operationName: "checkTraceExists",
|
||||
projectId,
|
||||
minStartTime: timestamp ?? exactTimestamp,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
@@ -163,6 +168,7 @@ export const checkTraceExists = async ({
|
||||
type: "trace",
|
||||
kind: "exists",
|
||||
projectId,
|
||||
operation_name: "checkTraceExists",
|
||||
},
|
||||
timestamp: timestamp ?? exactTimestamp,
|
||||
},
|
||||
@@ -186,7 +192,7 @@ export const checkTraceExists = async ({
|
||||
const rows = await queryClickhouse<{ id: string; project_id: string }>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -200,7 +206,7 @@ export const checkTraceExists = async ({
|
||||
t.project_id as project_id,
|
||||
-- Add a timestamp alias to ensure we can filter on it
|
||||
t.start_time as timestamp
|
||||
FROM ${traceAmt} t
|
||||
FROM ${traceAmt} t FINAL
|
||||
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
|
||||
WHERE ${tracesFilterRes.query}
|
||||
AND t.project_id = {projectId: String}
|
||||
@@ -209,7 +215,7 @@ export const checkTraceExists = async ({
|
||||
const rows = await queryClickhouse<{ id: string; project_id: string }>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -225,6 +231,7 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
|
||||
if (!["id", "project_id", "timestamp"].every((key) => key in trace)) {
|
||||
throw new Error("Identifier fields must be provided to upsert Trace.");
|
||||
}
|
||||
|
||||
await upsertClickhouse({
|
||||
table: "traces",
|
||||
records: [trace as TraceRecordReadType],
|
||||
@@ -236,6 +243,38 @@ export const upsertTrace = async (trace: Partial<TraceRecordReadType>) => {
|
||||
projectId: trace.project_id ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Also insert into traces_null if experiment flag is enabled
|
||||
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
|
||||
// Convert trace to insert format first (since we have read format)
|
||||
const traceRecord = trace as TraceRecordReadType;
|
||||
const traceInsert = {
|
||||
...traceRecord,
|
||||
timestamp: new Date(traceRecord.timestamp).getTime(),
|
||||
created_at: new Date(traceRecord.created_at).getTime(),
|
||||
updated_at: new Date(traceRecord.updated_at).getTime(),
|
||||
event_ts: new Date(traceRecord.event_ts).getTime(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
|
||||
// Convert to traces_null format
|
||||
const traceNull = convertTraceToTraceNull(traceInsert);
|
||||
|
||||
// Insert directly into traces_null using clickhouse client
|
||||
await clickhouseClient().insert({
|
||||
table: "traces_null",
|
||||
format: "JSONEachRow",
|
||||
values: [traceNull],
|
||||
clickhouse_settings: {
|
||||
log_comment: JSON.stringify({
|
||||
feature: "tracing",
|
||||
type: "traces_null",
|
||||
kind: "upsert",
|
||||
experiment: "insert_into_aggregating_merge_trees",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getTracesByIds = async (
|
||||
@@ -260,6 +299,7 @@ export const getTracesByIds = async (
|
||||
type: "trace",
|
||||
kind: "byId",
|
||||
projectId,
|
||||
operation_name: "getTracesByIds",
|
||||
},
|
||||
clickhouseConfigs,
|
||||
},
|
||||
@@ -276,7 +316,7 @@ export const getTracesByIds = async (
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
clickhouseConfigs: input.clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
@@ -311,13 +351,15 @@ export const getTracesByIds = async (
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs: input.clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return records.map(convertClickhouseToDomain);
|
||||
return records.map((record) =>
|
||||
convertClickhouseToDomain(record, DEFAULT_RENDERING_PROPS),
|
||||
);
|
||||
};
|
||||
|
||||
export const getTracesBySessionId = async (
|
||||
@@ -328,6 +370,7 @@ export const getTracesBySessionId = async (
|
||||
const records = await measureAndReturn({
|
||||
operationName: "getTracesBySessionId",
|
||||
projectId,
|
||||
minStartTime: timestamp,
|
||||
input: {
|
||||
params: {
|
||||
sessionIds,
|
||||
@@ -341,6 +384,7 @@ export const getTracesBySessionId = async (
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
operation_name: "getTracesBySessionId",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
@@ -357,7 +401,7 @@ export const getTracesBySessionId = async (
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
@@ -388,12 +432,14 @@ export const getTracesBySessionId = async (
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const traces = records.map(convertClickhouseToDomain);
|
||||
const traces = records.map((record) =>
|
||||
convertClickhouseToDomain(record, DEFAULT_RENDERING_PROPS),
|
||||
);
|
||||
|
||||
traces.forEach((trace) => {
|
||||
recordDistribution(
|
||||
@@ -416,6 +462,7 @@ export const hasAnyTrace = async (projectId: string) => {
|
||||
type: "trace",
|
||||
kind: "hasAny",
|
||||
projectId,
|
||||
operation_name: "hasAnyTrace",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -431,7 +478,7 @@ export const hasAnyTrace = async (projectId: string) => {
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -449,7 +496,7 @@ export const hasAnyTrace = async (projectId: string) => {
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -467,6 +514,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
|
||||
return measureAndReturn({
|
||||
operationName: "getTraceCountsByProjectInCreationInterval",
|
||||
projectId: "__CROSS_PROJECT__",
|
||||
minStartTime: start,
|
||||
input: {
|
||||
params: {
|
||||
start: convertDateToClickhouseDateTime(start),
|
||||
@@ -476,6 +524,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
operation_name: "getTraceCountsByProjectInCreationInterval",
|
||||
},
|
||||
timestamp: start,
|
||||
},
|
||||
@@ -494,7 +543,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
|
||||
{
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -519,7 +568,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
|
||||
{
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -541,6 +590,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
|
||||
return measureAndReturn({
|
||||
operationName: "getTraceCountOfProjectsSinceCreationDate",
|
||||
projectId: "__CROSS_PROJECT__",
|
||||
minStartTime: start,
|
||||
input: {
|
||||
params: {
|
||||
projectIds,
|
||||
@@ -550,6 +600,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
operation_name: "getTraceCountOfProjectsSinceCreationDate",
|
||||
},
|
||||
timestamp: start,
|
||||
},
|
||||
@@ -565,7 +616,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
|
||||
const rows = await queryClickhouse<{ count: string }>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
@@ -583,7 +634,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
|
||||
const rows = await queryClickhouse<{ count: string }>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
@@ -603,15 +654,18 @@ export const getTraceById = async ({
|
||||
projectId,
|
||||
timestamp,
|
||||
fromTimestamp,
|
||||
renderingProps = DEFAULT_RENDERING_PROPS,
|
||||
}: {
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
timestamp?: Date;
|
||||
fromTimestamp?: Date;
|
||||
renderingProps?: RenderingProps;
|
||||
}) => {
|
||||
const records = await measureAndReturn({
|
||||
operationName: "getTraceById",
|
||||
projectId,
|
||||
minStartTime: fromTimestamp ?? timestamp,
|
||||
input: {
|
||||
params: {
|
||||
traceId,
|
||||
@@ -628,11 +682,30 @@ export const getTraceById = async ({
|
||||
type: "trace",
|
||||
kind: "byId",
|
||||
projectId,
|
||||
operation_name: "getTraceById",
|
||||
},
|
||||
},
|
||||
existingExecution: (input) => {
|
||||
const query = `
|
||||
SELECT *
|
||||
SELECT
|
||||
id,
|
||||
name as name,
|
||||
user_id as user_id,
|
||||
metadata as metadata,
|
||||
release as release,
|
||||
version as version,
|
||||
project_id,
|
||||
environment,
|
||||
public as public,
|
||||
bookmarked as bookmarked,
|
||||
tags,
|
||||
${renderingProps.truncated ? `left(input, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "input"} as input,
|
||||
${renderingProps.truncated ? `left(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "output"} as output,
|
||||
session_id as session_id,
|
||||
0 as is_deleted,
|
||||
timestamp,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM traces
|
||||
WHERE id = {traceId: String}
|
||||
AND project_id = {projectId: String}
|
||||
@@ -645,7 +718,7 @@ export const getTraceById = async ({
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
@@ -662,8 +735,8 @@ export const getTraceById = async ({
|
||||
finalizeAggregation(public) as public,
|
||||
finalizeAggregation(bookmarked) as bookmarked,
|
||||
tags,
|
||||
finalizeAggregation(input) as input,
|
||||
finalizeAggregation(output) as output,
|
||||
${renderingProps.truncated ? `left(finalizeAggregation(input), ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "finalizeAggregation(input)"} as input,
|
||||
${renderingProps.truncated ? `left(finalizeAggregation(output), ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})` : "finalizeAggregation(output)"} as output,
|
||||
session_id as session_id,
|
||||
0 as is_deleted,
|
||||
start_time as timestamp,
|
||||
@@ -679,12 +752,14 @@ export const getTraceById = async ({
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const res = records.map(convertClickhouseToDomain);
|
||||
const res = records.map((record) =>
|
||||
convertClickhouseToDomain(record, renderingProps),
|
||||
);
|
||||
|
||||
res.forEach((trace) => {
|
||||
recordDistribution(
|
||||
@@ -715,6 +790,10 @@ export const getTracesGroupedByName = async (
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByName",
|
||||
projectId,
|
||||
minStartTime: timestampFilter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
@@ -725,6 +804,7 @@ export const getTracesGroupedByName = async (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getTracesGroupedByName",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -749,7 +829,7 @@ export const getTracesGroupedByName = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
@@ -778,7 +858,7 @@ export const getTracesGroupedByName = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -809,6 +889,10 @@ export const getTracesGroupedByUsers = async (
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByUsers",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
limit,
|
||||
@@ -822,6 +906,7 @@ export const getTracesGroupedByUsers = async (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getTracesGroupedByUsers",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -848,7 +933,7 @@ export const getTracesGroupedByUsers = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
@@ -880,7 +965,7 @@ export const getTracesGroupedByUsers = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -905,6 +990,10 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByTags",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
@@ -915,6 +1004,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getTracesGroupedByTags",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -931,7 +1021,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
@@ -955,7 +1045,7 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -978,6 +1068,7 @@ export const getTracesIdentifierForSession = async (
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
operation_name: "getTracesIdentifierForSession",
|
||||
},
|
||||
},
|
||||
existingExecution: (input) => {
|
||||
@@ -1005,7 +1096,7 @@ export const getTracesIdentifierForSession = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
@@ -1017,10 +1108,11 @@ export const getTracesIdentifierForSession = async (
|
||||
start_time as timestamp,
|
||||
project_id,
|
||||
environment
|
||||
FROM traces_all_amt FINAL
|
||||
FROM traces_all_amt
|
||||
WHERE (project_id = {projectId: String})
|
||||
AND (session_id = {sessionId: String})
|
||||
ORDER BY start_time ASC;
|
||||
ORDER BY start_time ASC
|
||||
LIMIT 1 BY id, project_id;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
@@ -1032,7 +1124,7 @@ export const getTracesIdentifierForSession = async (
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1047,25 +1139,104 @@ export const getTracesIdentifierForSession = async (
|
||||
};
|
||||
|
||||
export const deleteTraces = async (projectId: string, traceIds: string[]) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
traceIds,
|
||||
await measureAndReturn({
|
||||
operationName: "deleteTraces",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
traceIds,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
newExecution: async (input) => {
|
||||
await Promise.all([
|
||||
// Delete from traces
|
||||
await commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_null
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_null
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_all_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_all_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_7d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_7d_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_30d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_30d_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND id IN ({traceIds: Array(String)});
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1074,47 +1245,187 @@ export const deleteTracesOlderThanDays = async (
|
||||
projectId: string,
|
||||
beforeDate: Date,
|
||||
) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp < {cutoffDate: DateTime64(3)};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
await measureAndReturn({
|
||||
operationName: "deleteTracesOlderThanDays",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp < {cutoffDate: DateTime64(3)};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
newExecution: async (input) => {
|
||||
await Promise.all([
|
||||
// Delete from traces
|
||||
await commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND timestamp < {cutoffDate: DateTime64(3)};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_all_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_all_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time < {cutoffDate: DateTime64(3)};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_7d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_7d_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time < {cutoffDate: DateTime64(3)};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_30d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_30d_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND start_time < {cutoffDate: DateTime64(3)};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTracesByProjectId = async (projectId: string) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
await measureAndReturn({
|
||||
operationName: "deleteTracesByProjectId",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
newExecution: async (input) => {
|
||||
await Promise.all([
|
||||
// Delete from traces
|
||||
await commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_null
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_null
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_all_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_all_amt
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_7d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_7d_amt
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
// Delete from traces_30d_amt
|
||||
commandClickhouse({
|
||||
query: `
|
||||
DELETE FROM traces_30d_amt
|
||||
WHERE project_id = {projectId: String};
|
||||
`,
|
||||
params: input.params,
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: input.tags,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1130,6 +1441,7 @@ export const hasAnyUser = async (projectId: string) => {
|
||||
type: "user",
|
||||
kind: "hasAny",
|
||||
projectId,
|
||||
operation_name: "hasAnyUser",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -1147,7 +1459,7 @@ export const hasAnyUser = async (projectId: string) => {
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -1167,7 +1479,7 @@ export const hasAnyUser = async (projectId: string) => {
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
@@ -1194,6 +1506,10 @@ export const getTotalUserCount = async (
|
||||
return measureAndReturn({
|
||||
operationName: "getTotalUserCount",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
...tracesFilterRes.params,
|
||||
@@ -1204,6 +1520,7 @@ export const getTotalUserCount = async (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getTotalUserCount",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -1219,7 +1536,7 @@ export const getTotalUserCount = async (
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
@@ -1242,7 +1559,7 @@ export const getTotalUserCount = async (
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1342,6 +1659,10 @@ export const getUserMetrics = async (
|
||||
return measureAndReturn({
|
||||
operationName: "getUserMetrics",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" && (f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
@@ -1360,6 +1681,7 @@ export const getUserMetrics = async (
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
operation_name: "getUserMetrics",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -1377,7 +1699,7 @@ export const getUserMetrics = async (
|
||||
}>({
|
||||
query: query.replaceAll("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -1415,7 +1737,7 @@ export const getUserMetrics = async (
|
||||
}>({
|
||||
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -1475,6 +1797,9 @@ export const getTracesForBlobStorageExport = function (
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1531,7 +1856,7 @@ export const getTracesForPostHog = async function* (
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 300_000, // 5 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DATA_EXPORT_REQUEST_TIMEOUT_MS,
|
||||
clickhouse_settings: {
|
||||
join_algorithm: "grace_hash",
|
||||
grace_hash_join_initial_buckets: "32",
|
||||
@@ -1585,6 +1910,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
operation_name: "getTracesByIdsForAnyProject",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
@@ -1600,7 +1926,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
@@ -1621,7 +1947,7 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
});
|
||||
|
||||
return records.map((record) => ({
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { TraceRecordReadType } from "./definitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { TraceDomain } from "../../domain";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import {
|
||||
RenderingProps,
|
||||
DEFAULT_RENDERING_PROPS,
|
||||
applyInputOutputRendering,
|
||||
} from "../utils/rendering";
|
||||
|
||||
export const convertTraceDomainToClickhouse = (
|
||||
trace: TraceDomain,
|
||||
@@ -33,6 +37,7 @@ export const convertTraceDomainToClickhouse = (
|
||||
|
||||
export const convertClickhouseToDomain = (
|
||||
record: TraceRecordReadType,
|
||||
renderingProps: RenderingProps = DEFAULT_RENDERING_PROPS,
|
||||
): TraceDomain => {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -47,10 +52,8 @@ export const convertClickhouseToDomain = (
|
||||
userId: record.user_id ?? null,
|
||||
sessionId: record.session_id ?? null,
|
||||
public: record.public,
|
||||
input: record.input ? (parseJsonPrioritised(record.input) ?? null) : null,
|
||||
output: record.output
|
||||
? (parseJsonPrioritised(record.output) ?? null)
|
||||
: null,
|
||||
input: applyInputOutputRendering(record.input, renderingProps),
|
||||
output: applyInputOutputRendering(record.output, renderingProps),
|
||||
metadata: parseMetadataCHRecordToDomain(record.metadata),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
|
||||
|
||||
@@ -104,6 +104,7 @@ export class StorageServiceFactory {
|
||||
}
|
||||
}
|
||||
|
||||
let azureContainersExists: Record<string, boolean> = {};
|
||||
class AzureBlobStorageService implements StorageService {
|
||||
private client: ContainerClient;
|
||||
private container: string;
|
||||
@@ -139,8 +140,18 @@ class AzureBlobStorageService implements StorageService {
|
||||
}
|
||||
|
||||
private async createContainerIfNotExists(): Promise<void> {
|
||||
// Skip container existence check if environment variable is set
|
||||
if (env.LANGFUSE_AZURE_SKIP_CONTAINER_CHECK === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (azureContainersExists[this.container]) {
|
||||
return; // Container already exists, no need to create it again
|
||||
}
|
||||
await this.client.createIfNotExists();
|
||||
azureContainersExists[this.container] = true; // Mark container as created
|
||||
logger.info(`Azure Blob Storage container ${this.container} created`);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to create Azure Blob Storage container ${this.container}`,
|
||||
|
||||
@@ -24,6 +24,8 @@ export type SessionDataReturnType = {
|
||||
trace_count: number;
|
||||
trace_tags: string[];
|
||||
trace_environment?: string;
|
||||
scores_avg?: Array<Array<[string, number]>>;
|
||||
score_categories?: Array<Array<string>>;
|
||||
};
|
||||
|
||||
export type SessionWithMetricsReturnType = SessionDataReturnType & {
|
||||
@@ -159,7 +161,9 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
session_total_cost,
|
||||
session_input_usage,
|
||||
session_output_usage,
|
||||
session_total_usage`;
|
||||
session_total_usage,
|
||||
scores_avg,
|
||||
score_categories`;
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveCheckDefault: never = select;
|
||||
@@ -167,7 +171,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
const { tracesFilter, scoresFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
tracesPrefix: "s",
|
||||
});
|
||||
|
||||
@@ -176,6 +180,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const tracesFilterRes = tracesFilter
|
||||
.filter((f) => f.field !== "environment")
|
||||
.apply();
|
||||
const scoresFilterRes = scoresFilter.apply();
|
||||
|
||||
const traceTimestampFilter: DateTimeFilter | undefined = tracesFilter.find(
|
||||
(f) =>
|
||||
@@ -207,6 +212,13 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
const singleTraceFilter =
|
||||
filters.length > 0 ? new FilterList(filters).apply() : undefined;
|
||||
|
||||
const requiresScoresJoin =
|
||||
tracesFilter.find((f) => f.clickhouseTable === "scores") !== undefined ||
|
||||
sessionCols.find(
|
||||
(c) =>
|
||||
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
|
||||
)?.clickhouseTableName === "scores";
|
||||
|
||||
const hasMetricsFilter =
|
||||
tracesFilter.find((f) =>
|
||||
[
|
||||
@@ -217,6 +229,8 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
"session_total_usage",
|
||||
"session_output_usage",
|
||||
"session_input_usage",
|
||||
"scores_avg",
|
||||
"score_categories",
|
||||
].includes(f.field),
|
||||
) ||
|
||||
(orderBy &&
|
||||
@@ -233,14 +247,53 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
|
||||
const selectMetrics = select === "metrics" || hasMetricsFilter;
|
||||
|
||||
const scoresCte = `scores_agg AS (
|
||||
SELECT
|
||||
project_id,
|
||||
session_id AS score_session_id,
|
||||
-- For numeric scores, use tuples of (name, avg_value)
|
||||
groupArrayIf(
|
||||
tuple(name, avg_value),
|
||||
data_type IN ('NUMERIC', 'BOOLEAN')
|
||||
) AS scores_avg,
|
||||
-- For categorical scores, use name:value format for improved query performance
|
||||
groupArrayIf(
|
||||
concat(name, ':', string_value),
|
||||
data_type = 'CATEGORICAL' AND notEmpty(string_value)
|
||||
) AS score_categories
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
session_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value,
|
||||
avg(value) avg_value
|
||||
FROM scores s FINAL
|
||||
WHERE
|
||||
project_id = {projectId: String}
|
||||
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
|
||||
GROUP BY
|
||||
project_id,
|
||||
session_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value
|
||||
) tmp
|
||||
GROUP BY
|
||||
project_id, session_id
|
||||
)`;
|
||||
|
||||
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
|
||||
const query = `
|
||||
WITH deduplicated_traces AS (
|
||||
WITH ${select === "metrics" || requiresScoresJoin ? `${scoresCte},` : ""}
|
||||
deduplicated_traces AS (
|
||||
SELECT * EXCEPT input, output, metadata
|
||||
FROM __TRACE_TABLE__ t FINAL
|
||||
FROM __TRACE_TABLE__ t
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
deduplicated_observations AS (
|
||||
SELECT *
|
||||
@@ -288,6 +341,12 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
date_diff('second', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
|
||||
sumMap(o.sum_usage_details) as session_usage_details,
|
||||
sumMap(o.sum_cost_details) as session_cost_details,
|
||||
${
|
||||
select === "metrics" || requiresScoresJoin
|
||||
? `groupUniqArrayArray(s.scores_avg) as scores_avg,
|
||||
groupUniqArrayArray(s.score_categories) as score_categories,`
|
||||
: ""
|
||||
}
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
|
||||
sumMap(o.sum_cost_details)['total'] as session_total_cost,
|
||||
@@ -303,6 +362,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id`
|
||||
: ""
|
||||
}
|
||||
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_agg s on s.project_id = t.project_id and t.session_id = s.score_session_id` : ""}
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
@@ -318,6 +378,11 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
return measureAndReturn({
|
||||
operationName: "getSessionsTableGeneric",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
@@ -325,6 +390,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...scoresFilterRes.params,
|
||||
...(traceTimestampFilter
|
||||
? {
|
||||
observationsStartTime: convertDateToClickhouseDateTime(
|
||||
@@ -338,13 +404,14 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
feature: "tracing",
|
||||
type: "sessions-table",
|
||||
projectId,
|
||||
operation_name: "getSessionsTableGeneric",
|
||||
},
|
||||
},
|
||||
existingExecution: async (input) => {
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", "traces"),
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "original" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
@@ -359,7 +426,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -350,6 +350,7 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesTableGeneric",
|
||||
projectId: props.projectId,
|
||||
minStartTime: select !== "metrics" ? timeStampFilter?.value : undefined,
|
||||
input: props,
|
||||
existingExecution: async (props) => {
|
||||
let sqlSelect: string;
|
||||
@@ -479,6 +480,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
feature: "tracing",
|
||||
type: "traces-table",
|
||||
projectId,
|
||||
experiment_amt: "original",
|
||||
operation_name: "getTracesTableGeneric",
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
@@ -541,12 +544,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
true,
|
||||
);
|
||||
|
||||
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
|
||||
const chOrderBy = orderByToClickhouseSql(
|
||||
[
|
||||
defaultOrder ? [{ column: "timestamp", order: orderBy.order }] : null,
|
||||
orderBy ?? null,
|
||||
].flat(),
|
||||
[orderBy ?? null].flat(),
|
||||
tracesTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
@@ -556,7 +555,7 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
: getTimeframesTracesAMT(timeStampFilter?.value);
|
||||
|
||||
const query = `
|
||||
${observationsAndScoresCTE}
|
||||
${observationsAndScoresCTE}
|
||||
|
||||
SELECT ${sqlSelect}
|
||||
FROM ${tracesAmt} t FINAL
|
||||
@@ -588,6 +587,8 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
feature: "tracing",
|
||||
type: "traces-table",
|
||||
projectId,
|
||||
experiment_amt: "new",
|
||||
operation_name: "getTracesTableGeneric",
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
@@ -4,18 +4,16 @@ import {
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
convertTraceToTraceMt,
|
||||
convertTraceToTraceNull,
|
||||
} from "../repositories/definitions";
|
||||
import { env } from "../../env";
|
||||
|
||||
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
|
||||
if (
|
||||
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES === "true"
|
||||
) {
|
||||
if (env.LANGFUSE_EXPERIMENT_INSERT_INTO_AGGREGATING_MERGE_TREES === "true") {
|
||||
await clickhouseClient().insert({
|
||||
table: "traces_mt",
|
||||
table: "traces_null",
|
||||
format: "JSONEachRow",
|
||||
values: trace.map(convertTraceToTraceMt),
|
||||
values: trace.map(convertTraceToTraceNull),
|
||||
});
|
||||
}
|
||||
return await clickhouseClient().insert({
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "../db";
|
||||
import { TraceDeleteQueue } from "./redis/traceDelete";
|
||||
import { QueueJobs } from "./queues";
|
||||
import { logger } from "./logger";
|
||||
import { env } from "../env";
|
||||
|
||||
export interface TraceDeletionProcessorOptions {
|
||||
delayMs?: number; // Default from LANGFUSE_TRACE_DELETE_DELAY_MS env var
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficient trace deletion processor that batches deletions for better performance.
|
||||
*
|
||||
* This function:
|
||||
* 1. Creates a record in the pending_deletions table for each trace
|
||||
* 2. Sends a deletion event to the queue with a configurable delay
|
||||
* 3. The worker will batch delete all pending traces from ClickHouse
|
||||
* 4. Sets the is_deleted flag to true after successful deletion
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param traceIds - Array of trace IDs to delete
|
||||
* @param options - Configuration options including delay
|
||||
*/
|
||||
export async function traceDeletionProcessor(
|
||||
projectId: string,
|
||||
traceIds: string[],
|
||||
options: TraceDeletionProcessorOptions = {},
|
||||
): Promise<void> {
|
||||
const { delayMs = env.LANGFUSE_TRACE_DELETE_DELAY_MS } = options;
|
||||
|
||||
if (traceIds.length === 0) {
|
||||
logger.warn("traceDeletionProcessor called with empty traceIds array", {
|
||||
projectId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processing trace deletion for ${traceIds.length} traces in project ${projectId}`,
|
||||
{
|
||||
projectId,
|
||||
traceIds,
|
||||
delayMs,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// Create pending deletion records for all traces
|
||||
await prisma.pendingDeletion.createMany({
|
||||
data: traceIds.map((traceId) => ({
|
||||
projectId,
|
||||
object: "trace",
|
||||
objectId: traceId,
|
||||
isDeleted: false,
|
||||
})),
|
||||
skipDuplicates: true, // Avoid conflicts if trace is already pending deletion
|
||||
});
|
||||
|
||||
// Get the trace delete queue
|
||||
const traceDeleteQueue = TraceDeleteQueue.getInstance();
|
||||
if (!traceDeleteQueue) {
|
||||
throw new Error("TraceDeleteQueue not available");
|
||||
}
|
||||
|
||||
// Send deletion event with delay
|
||||
await traceDeleteQueue.add(
|
||||
QueueJobs.TraceDelete,
|
||||
{
|
||||
timestamp: new Date(),
|
||||
id: randomUUID(),
|
||||
name: QueueJobs.TraceDelete,
|
||||
payload: {
|
||||
projectId,
|
||||
traceIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
delay: delayMs,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process trace deletion for project ${projectId}`, {
|
||||
projectId,
|
||||
traceIds,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { JsonNested } from "../../utils/zod";
|
||||
import { parseJsonPrioritised } from "../../utils/json";
|
||||
import { env } from "../../env";
|
||||
|
||||
/**
|
||||
* Rendering properties used to control how data is processed and returned
|
||||
* in tRPC routes and repository functions.
|
||||
*/
|
||||
export interface RenderingProps {
|
||||
/**
|
||||
* Whether to truncate input/output fields to a specific character limit
|
||||
*/
|
||||
truncated: boolean;
|
||||
|
||||
/**
|
||||
* Whether to skip JSON parsing of input/output fields and return them as raw strings.
|
||||
* This is useful when the client will handle JSON parsing to avoid double parsing.
|
||||
*/
|
||||
shouldJsonParse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default rendering properties
|
||||
*/
|
||||
export const DEFAULT_RENDERING_PROPS: RenderingProps = {
|
||||
truncated: false,
|
||||
shouldJsonParse: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform input/output fields based on rendering properties.
|
||||
*/
|
||||
export const applyInputOutputRendering = (
|
||||
io: string | null | undefined,
|
||||
renderingProps: RenderingProps,
|
||||
): JsonNested | string | null => {
|
||||
if (!io) return null;
|
||||
let result: JsonNested | string = io;
|
||||
|
||||
if (
|
||||
renderingProps.truncated &&
|
||||
io.length > env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT
|
||||
) {
|
||||
result =
|
||||
io.slice(0, env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT) + "\n...[truncated]";
|
||||
}
|
||||
|
||||
if (
|
||||
renderingProps.truncated &&
|
||||
io.length === env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT
|
||||
) {
|
||||
result = io + "\n...[truncated]";
|
||||
}
|
||||
|
||||
return renderingProps.shouldJsonParse
|
||||
? (parseJsonPrioritised(result) ?? null)
|
||||
: result;
|
||||
};
|
||||
@@ -19,4 +19,10 @@ export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
|
||||
clickhouseTableName: "dataset_run_items",
|
||||
clickhouseSelect: 'dri."event_ts"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Dataset Item ID",
|
||||
uiTableId: "datasetItemId",
|
||||
clickhouseTableName: "dataset_run_items",
|
||||
clickhouseSelect: 'dri."dataset_item_id"',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -138,4 +138,16 @@ export const sessionCols: UiColumnMappings = [
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: "session_id",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores (numeric)",
|
||||
uiTableId: "scores",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: "scores_avg",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores (categorical)",
|
||||
uiTableId: "scores",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: "score_categories",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
type SingleValueOption,
|
||||
type MultiValueOption,
|
||||
} from "../tableDefinitions/types";
|
||||
import { formatColumnOptions } from "./typeHelpers";
|
||||
|
||||
@@ -87,11 +88,27 @@ export const sessionsViewCols: ColumnDefinition[] = [
|
||||
internal: 't."tags"',
|
||||
options: [], // to be filled in at runtime
|
||||
},
|
||||
{
|
||||
name: "Scores (numeric)",
|
||||
id: "scores_avg",
|
||||
type: "numberObject",
|
||||
internal: "scores",
|
||||
},
|
||||
{
|
||||
name: "Scores (categorical)",
|
||||
id: "score_categories",
|
||||
type: "categoryOptions",
|
||||
internal: "score_categories",
|
||||
options: [], // to be added at runtime
|
||||
nullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type SessionOptions = {
|
||||
userIds: Array<SingleValueOption>;
|
||||
tags: Array<SingleValueOption>;
|
||||
scores_avg?: Array<string>;
|
||||
score_categories?: Array<MultiValueOption>;
|
||||
};
|
||||
|
||||
export function sessionsTableColsWithOptions(
|
||||
@@ -104,6 +121,12 @@ export function sessionsTableColsWithOptions(
|
||||
if (col.id === "tags") {
|
||||
return formatColumnOptions(col, options?.tags ?? []);
|
||||
}
|
||||
if (col.id === "scores_avg") {
|
||||
return formatColumnOptions(col, options?.scores_avg ?? []);
|
||||
}
|
||||
if (col.id === "score_categories") {
|
||||
return formatColumnOptions(col, options?.score_categories ?? []);
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export const tableNames = [
|
||||
"job_configurations",
|
||||
"job_executions",
|
||||
"dataset_items",
|
||||
"annotation_queue_assignments",
|
||||
] as const;
|
||||
|
||||
export type TableNames = (typeof tableNames)[number];
|
||||
|
||||
@@ -41,11 +41,11 @@ export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
|
||||
export const paginationZod = {
|
||||
page: z.preprocess(
|
||||
(x) => (x === "" ? undefined : x),
|
||||
z.coerce.number().default(1),
|
||||
z.coerce.number().nonnegative().default(1),
|
||||
),
|
||||
limit: z.preprocess(
|
||||
(x) => (x === "" ? undefined : x),
|
||||
z.coerce.number().lte(100).default(50),
|
||||
z.coerce.number().nonnegative().lte(100).default(50),
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ const cspHeader = `
|
||||
default-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.posthog.com https://*.sentry.io;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://*.langfuse.com https://*.langfuse.dev https://challenges.cloudflare.com https://*.sentry.io https://static.cloudflareinsights.com https://*.stripe.com https://uptime.betterstack.com https://chat.cdn-plain.com;
|
||||
style-src 'self' 'unsafe-inline' https://uptime.betterstack.com https://fonts.googleapis.com;
|
||||
img-src 'self' https: blob: data: http://localhost:*;
|
||||
img-src 'self' https: blob: data: http://localhost:* https://prod-uk-services-workspac-workspacefilespublicbuck-vs4gjqpqjkh6.s3.amazonaws.com https://prod-uk-services-attachm-attachmentsbucket28b3ccf-uwfssb4vt2us.s3.eu-west-2.amazonaws.com https://i0.wp.com;
|
||||
font-src 'self';
|
||||
frame-src 'self' https://challenges.cloudflare.com https://*.stripe.com;
|
||||
worker-src 'self' blob:;
|
||||
@@ -23,7 +23,7 @@ const cspHeader = `
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.ingest.us.sentry.io https://*.sentry.io https://uptime.betterstack.com https://chat.uk.plain.com https://*.s3.amazonaws.com;
|
||||
connect-src 'self' https://*.langfuse.com https://*.langfuse.dev https://*.ingest.us.sentry.io https://*.sentry.io https://uptime.betterstack.com https://chat.uk.plain.com https://*.s3.amazonaws.com https://prod-uk-services-attachm-attachmentsuploadbucket2-1l2e4906o2asm.s3.eu-west-2.amazonaws.com;
|
||||
media-src 'self' https: http://localhost:*;
|
||||
${env.LANGFUSE_CSP_ENFORCE_HTTPS === "true" ? "upgrade-insecure-requests; block-all-mixed-content;" : ""}
|
||||
${env.SENTRY_CSP_REPORT_URI ? `report-uri ${env.SENTRY_CSP_REPORT_URI}; report-to csp-endpoint;` : ""}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.89.0",
|
||||
"version": "3.95.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -409,6 +409,111 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/annotation-queues/{queueId}/assignments:
|
||||
post:
|
||||
description: Create an assignment for a user to an annotation queue
|
||||
operationId: annotationQueues_createQueueAssignment
|
||||
tags:
|
||||
- AnnotationQueues
|
||||
parameters:
|
||||
- name: queueId
|
||||
in: path
|
||||
description: The unique identifier of the annotation queue
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAnnotationQueueAssignmentResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
|
||||
delete:
|
||||
description: Delete an assignment for a user to an annotation queue
|
||||
operationId: annotationQueues_deleteQueueAssignment
|
||||
tags:
|
||||
- AnnotationQueues
|
||||
parameters:
|
||||
- name: queueId
|
||||
in: path
|
||||
description: The unique identifier of the annotation queue
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DeleteAnnotationQueueAssignmentResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationQueueAssignmentRequest'
|
||||
/api/public/comments:
|
||||
post:
|
||||
description: >-
|
||||
@@ -1421,6 +1526,109 @@ paths:
|
||||
name: My Score
|
||||
value: 0.9
|
||||
environment: default
|
||||
/api/public/llm-connections:
|
||||
get:
|
||||
description: Get all LLM connections in a project
|
||||
operationId: llmConnections_list
|
||||
tags:
|
||||
- LlmConnections
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: page number, starts at 1
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: limit
|
||||
in: query
|
||||
description: limit of items per page
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedLlmConnections'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update an LLM connection. The connection is upserted on
|
||||
provider.
|
||||
operationId: llmConnections_upsert
|
||||
tags:
|
||||
- LlmConnections
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LlmConnection'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpsertLlmConnectionRequest'
|
||||
/api/public/media/{mediaId}:
|
||||
get:
|
||||
description: Get a media record
|
||||
@@ -3046,7 +3254,7 @@ paths:
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
description: Filter expression (e.g. userName eq "value")
|
||||
description: Filter expression (e.g. userName eq 'value')
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
@@ -4284,6 +4492,36 @@ components:
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
AnnotationQueueAssignmentRequest:
|
||||
title: AnnotationQueueAssignmentRequest
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
DeleteAnnotationQueueAssignmentResponse:
|
||||
title: DeleteAnnotationQueueAssignmentResponse
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
CreateAnnotationQueueAssignmentResponse:
|
||||
title: CreateAnnotationQueueAssignmentResponse
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
queueId:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
- queueId
|
||||
- projectId
|
||||
CreateCommentRequest:
|
||||
title: CreateCommentRequest
|
||||
type: object
|
||||
@@ -6225,6 +6463,122 @@ components:
|
||||
type: integer
|
||||
- $ref: '#/components/schemas/OpenAICompletionUsageSchema'
|
||||
- $ref: '#/components/schemas/OpenAIResponseUsageSchema'
|
||||
LlmConnection:
|
||||
title: LlmConnection
|
||||
type: object
|
||||
description: LLM API connection configuration (secrets excluded)
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
description: >-
|
||||
Provider name (e.g., 'openai', 'my-gateway'). Must be unique in
|
||||
project, used for upserting.
|
||||
adapter:
|
||||
type: string
|
||||
description: The adapter used to interface with the LLM
|
||||
displaySecretKey:
|
||||
type: string
|
||||
description: Masked version of the secret key for display purposes
|
||||
baseURL:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom base URL for the LLM API
|
||||
customModels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of custom model names available for this connection
|
||||
withDefaultModels:
|
||||
type: boolean
|
||||
description: Whether to include default models for this adapter
|
||||
extraHeaderKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >-
|
||||
Keys of extra headers sent with requests (values excluded for
|
||||
security)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- provider
|
||||
- adapter
|
||||
- displaySecretKey
|
||||
- customModels
|
||||
- withDefaultModels
|
||||
- extraHeaderKeys
|
||||
- createdAt
|
||||
- updatedAt
|
||||
PaginatedLlmConnections:
|
||||
title: PaginatedLlmConnections
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmConnection'
|
||||
meta:
|
||||
$ref: '#/components/schemas/utilsMetaResponse'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
UpsertLlmConnectionRequest:
|
||||
title: UpsertLlmConnectionRequest
|
||||
type: object
|
||||
description: Request to create or update an LLM connection (upsert)
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
description: >-
|
||||
Provider name (e.g., 'openai', 'my-gateway'). Must be unique in
|
||||
project, used for upserting.
|
||||
adapter:
|
||||
$ref: '#/components/schemas/LlmAdapter'
|
||||
description: The adapter used to interface with the LLM
|
||||
secretKey:
|
||||
type: string
|
||||
description: Secret key for the LLM API.
|
||||
baseURL:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom base URL for the LLM API
|
||||
customModels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of custom model names
|
||||
withDefaultModels:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: Whether to include default models. Default is true.
|
||||
extraHeaders:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Extra headers to send with requests
|
||||
required:
|
||||
- provider
|
||||
- adapter
|
||||
- secretKey
|
||||
LlmAdapter:
|
||||
title: LlmAdapter
|
||||
type: string
|
||||
enum:
|
||||
- anthropic
|
||||
- openai
|
||||
- azure
|
||||
- bedrock
|
||||
- google-vertex-ai
|
||||
- google-ai-studio
|
||||
GetMediaResponse:
|
||||
title: GetMediaResponse
|
||||
type: object
|
||||
|
||||
@@ -324,6 +324,88 @@
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Create Queue Assignment",
|
||||
"request": {
|
||||
"description": "Create an assignment for a user to an annotation queue",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/annotation-queues/:queueId/assignments",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"annotation-queues",
|
||||
":queueId",
|
||||
"assignments"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "queueId",
|
||||
"value": "",
|
||||
"description": "The unique identifier of the annotation queue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "POST",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Delete Queue Assignment",
|
||||
"request": {
|
||||
"description": "Delete an assignment for a user to an annotation queue",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/annotation-queues/:queueId/assignments",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"annotation-queues",
|
||||
":queueId",
|
||||
"assignments"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "queueId",
|
||||
"value": "",
|
||||
"description": "The unique identifier of the annotation queue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "DELETE",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1129,6 +1211,82 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Llm Connections",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "Get all LLM connections in a project",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/llm-connections?page=&limit=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"llm-connections"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "",
|
||||
"description": "page number, starts at 1"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "limit of items per page"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Upsert",
|
||||
"request": {
|
||||
"description": "Create or update an LLM connection. The connection is upserted on provider.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/llm-connections",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"llm-connections"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "PUT",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"provider\": \"example\",\n \"adapter\": \"anthropic\",\n \"secretKey\": \"example\",\n \"baseURL\": \"example\",\n \"customModels\": [\n \"example\"\n ],\n \"withDefaultModels\": true,\n \"extraHeaders\": {\n \"example\": \"example\"\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
makeAPICall,
|
||||
pruneDatabase,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
CreateAnnotationQueueAssignmentResponse,
|
||||
DeleteAnnotationQueueAssignmentResponse,
|
||||
} from "@/src/features/public-api/types/annotation-queues";
|
||||
|
||||
describe("/api/public/annotation-queues/:queueId/assignments API", () => {
|
||||
let auth: string;
|
||||
let projectId: string;
|
||||
let orgId: string;
|
||||
let queueId: string;
|
||||
let testUserId: string;
|
||||
let secondTestUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const {
|
||||
auth: newAuth,
|
||||
projectId: newProjectId,
|
||||
orgId: newOrgId,
|
||||
} = await createOrgProjectAndApiKey();
|
||||
auth = newAuth;
|
||||
projectId = newProjectId;
|
||||
orgId = newOrgId;
|
||||
|
||||
// Create a test annotation queue
|
||||
const queue = await prisma.annotationQueue.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: "Test Queue for Assignments",
|
||||
description: "Test queue for assignment testing",
|
||||
projectId,
|
||||
scoreConfigIds: [],
|
||||
},
|
||||
});
|
||||
queueId = queue.id;
|
||||
|
||||
// Create test users with project access
|
||||
const testUser = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
email: `testuser1-${uuidv4()}@example.com`,
|
||||
name: "Test User 1",
|
||||
},
|
||||
});
|
||||
testUserId = testUser.id;
|
||||
|
||||
const secondTestUser = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
email: `testuser2-${uuidv4()}@example.com`,
|
||||
name: "Test User 2",
|
||||
},
|
||||
});
|
||||
secondTestUserId = secondTestUser.id;
|
||||
|
||||
// Give users project access - need to create org memberships first
|
||||
await prisma.organizationMembership.createMany({
|
||||
data: [
|
||||
{
|
||||
orgId,
|
||||
userId: testUserId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
orgId,
|
||||
userId: secondTestUserId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get the org membership IDs and create project memberships
|
||||
const orgMembership1 = await prisma.organizationMembership.findFirst({
|
||||
where: { orgId, userId: testUserId },
|
||||
});
|
||||
const orgMembership2 = await prisma.organizationMembership.findFirst({
|
||||
where: { orgId, userId: secondTestUserId },
|
||||
});
|
||||
|
||||
await prisma.projectMembership.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId,
|
||||
userId: testUserId,
|
||||
role: "MEMBER",
|
||||
orgMembershipId: orgMembership1!.id,
|
||||
},
|
||||
{
|
||||
projectId,
|
||||
userId: secondTestUserId,
|
||||
role: "MEMBER",
|
||||
orgMembershipId: orgMembership2!.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pruneDatabase();
|
||||
});
|
||||
|
||||
describe("POST /api/public/annotation-queues/:queueId/assignments", () => {
|
||||
it("should create annotation queue assignment successfully", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
CreateAnnotationQueueAssignmentResponse,
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: testUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.userId).toBe(testUserId);
|
||||
expect(response.body.projectId).toBe(projectId);
|
||||
expect(response.body.queueId).toBe(queueId);
|
||||
|
||||
// Verify the membership was created in the database
|
||||
const assignment = await prisma.annotationQueueAssignment.findUnique({
|
||||
where: {
|
||||
projectId_queueId_userId: {
|
||||
projectId,
|
||||
queueId,
|
||||
userId: testUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(assignment).toBeTruthy();
|
||||
expect(assignment?.userId).toBe(testUserId);
|
||||
expect(assignment?.projectId).toBe(projectId);
|
||||
expect(assignment?.queueId).toBe(queueId);
|
||||
});
|
||||
|
||||
it("should handle duplicate assignment creation gracefully", async () => {
|
||||
// Create assignment first time
|
||||
await makeZodVerifiedAPICall(
|
||||
CreateAnnotationQueueAssignmentResponse,
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: secondTestUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
// Create same assignment again - should succeed (upsert behavior)
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
CreateAnnotationQueueAssignmentResponse,
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: secondTestUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.userId).toBe(secondTestUserId);
|
||||
|
||||
// Verify only one assignment exists
|
||||
const assignments = await prisma.annotationQueueAssignment.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
queueId,
|
||||
userId: secondTestUserId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent annotation queue", async () => {
|
||||
const nonExistentQueueId = uuidv4();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${nonExistentQueueId}/assignments`,
|
||||
{
|
||||
userId: testUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent user", async () => {
|
||||
const nonExistentUserId = uuidv4();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: nonExistentUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 404 for user without project access", async () => {
|
||||
// Create a user without project access
|
||||
const userWithoutAccess = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
email: `noaccess-${uuidv4()}@example.com`,
|
||||
name: "No Access User",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: userWithoutAccess.id,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should validate request body", async () => {
|
||||
// Missing userId
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should require valid queueId format", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/annotation-queues/invalid-queue-id/assignments`,
|
||||
{
|
||||
userId: testUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/public/annotation-queues/:queueId/assignments", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure assignment exists for delete tests
|
||||
await prisma.annotationQueueAssignment.upsert({
|
||||
where: {
|
||||
projectId_queueId_userId: {
|
||||
projectId,
|
||||
queueId,
|
||||
userId: testUserId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
queueId,
|
||||
userId: testUserId,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete annotation queue membership successfully", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
DeleteAnnotationQueueAssignmentResponse,
|
||||
"DELETE",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: testUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
// Verify the membership was deleted from the database
|
||||
const assignment = await prisma.annotationQueueAssignment.findUnique({
|
||||
where: {
|
||||
projectId_queueId_userId: {
|
||||
projectId,
|
||||
queueId,
|
||||
userId: testUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(assignment).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle deletion of non-existent assignment gracefully", async () => {
|
||||
// Delete a assignment that doesn't exist
|
||||
const nonExistentUserId = uuidv4();
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
DeleteAnnotationQueueAssignmentResponse,
|
||||
"DELETE",
|
||||
`/api/public/annotation-queues/${queueId}/assignments`,
|
||||
{
|
||||
userId: nonExistentUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent annotation queue", async () => {
|
||||
const nonExistentQueueId = uuidv4();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/annotation-queues/${nonExistentQueueId}/assignments`,
|
||||
{
|
||||
userId: testUserId,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { Prisma, type Role } from "@langfuse/shared";
|
||||
import { v4 } from "uuid";
|
||||
import { getUserProjectRoles } from "@/src/features/rbac/utils/userProjectRole";
|
||||
|
||||
export const createOrgAndProject = async () => {
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
name: v4(),
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { org, project };
|
||||
};
|
||||
|
||||
describe("find user project roles", () => {
|
||||
it("should find users with org role", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude users with NONE role", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: "NONE",
|
||||
},
|
||||
});
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it("should find users with org and project role", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
const user2 = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembershipOfUser2 = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
orgId: org.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
// overwrite org role to ADMIN
|
||||
await prisma.projectMembership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
projectId: project.id,
|
||||
role: "ADMIN",
|
||||
orgMembershipId: orgMembershipOfUser2.id,
|
||||
},
|
||||
});
|
||||
|
||||
const user3 = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembershipOfUser3 = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user3.id,
|
||||
orgId: org.id,
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
// Downgrade org role to VIEWER
|
||||
await prisma.projectMembership.create({
|
||||
data: {
|
||||
userId: user3.id,
|
||||
projectId: project.id,
|
||||
role: "VIEWER",
|
||||
orgMembershipId: orgMembershipOfUser3.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: user2.id,
|
||||
name: user2.name,
|
||||
email: user2.email,
|
||||
role: "ADMIN",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: user3.id,
|
||||
name: user3.name,
|
||||
email: user3.email,
|
||||
role: "VIEWER",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not select users from different projects", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
// valid user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
const { org: org2, project: project2 } = await createOrgAndProject();
|
||||
|
||||
const user2 = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
orgId: org2.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
const user3 = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembershipOfUser3 = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user3.id,
|
||||
orgId: org2.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.projectMembership.create({
|
||||
data: {
|
||||
userId: user3.id,
|
||||
projectId: project2.id,
|
||||
role: "MEMBER",
|
||||
orgMembershipId: orgMembershipOfUser3.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty organization", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it("should exclude users with project role NONE even if they have org membership", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
email: v4(),
|
||||
name: v4(),
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
// Project role NONE should exclude user despite ADMIN org role
|
||||
await prisma.projectMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
role: "NONE",
|
||||
orgMembershipId: orgMembership.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it("should test all role combinations inheritance vs override", async () => {
|
||||
const { org, project } = await createOrgAndProject();
|
||||
|
||||
// Test inheritance: users with only org roles
|
||||
const inheritanceTests = [
|
||||
{ orgRole: "OWNER", expectedRole: "OWNER" },
|
||||
{ orgRole: "ADMIN", expectedRole: "ADMIN" },
|
||||
{ orgRole: "MEMBER", expectedRole: "MEMBER" },
|
||||
{ orgRole: "VIEWER", expectedRole: "VIEWER" },
|
||||
];
|
||||
|
||||
const inheritanceUsers = [];
|
||||
for (const test of inheritanceTests) {
|
||||
const id = v4();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: `${id}-${test.orgRole}@test.com`,
|
||||
name: `${id}-${test.orgRole} User`,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: test.orgRole as Role,
|
||||
},
|
||||
});
|
||||
|
||||
inheritanceUsers.push({
|
||||
id,
|
||||
role: test.expectedRole,
|
||||
});
|
||||
}
|
||||
|
||||
// Test overrides: users with project roles that override org roles
|
||||
const overrideTests = [
|
||||
{ orgRole: "OWNER", projectRole: "VIEWER", expectedRole: "VIEWER" },
|
||||
{ orgRole: "MEMBER", projectRole: "ADMIN", expectedRole: "ADMIN" },
|
||||
{ orgRole: "VIEWER", projectRole: "MEMBER", expectedRole: "MEMBER" },
|
||||
{ orgRole: "ADMIN", projectRole: "VIEWER", expectedRole: "VIEWER" },
|
||||
];
|
||||
|
||||
const overrideUsers = [];
|
||||
for (const test of overrideTests) {
|
||||
const id = v4();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: `${id}-${test.orgRole}-${test.projectRole}@test.com`,
|
||||
name: `${id}-${test.orgRole}-${test.projectRole} User`,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: org.id,
|
||||
role: test.orgRole as Role,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.projectMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
role: test.projectRole as Role,
|
||||
orgMembershipId: orgMembership.id,
|
||||
},
|
||||
});
|
||||
|
||||
overrideUsers.push({
|
||||
id: user.id,
|
||||
role: test.expectedRole,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await getUserProjectRoles({
|
||||
projectId: project.id,
|
||||
orgId: org.id,
|
||||
filterCondition: [],
|
||||
searchFilter: Prisma.empty,
|
||||
orderBy: Prisma.empty,
|
||||
});
|
||||
|
||||
// Verify all inheritance cases
|
||||
for (const expectedUser of inheritanceUsers) {
|
||||
expect(users).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expectedUser.id,
|
||||
role: expectedUser.role,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify all override cases
|
||||
for (const expectedUser of overrideUsers) {
|
||||
expect(users).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expectedUser.id,
|
||||
role: expectedUser.role,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify total count
|
||||
expect(users).toHaveLength(inheritanceUsers.length + overrideUsers.length);
|
||||
});
|
||||
});
|
||||
@@ -316,36 +316,40 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
"<",
|
||||
"#",
|
||||
"|",
|
||||
])("should test special S3 characters in IDs (%s)", async (char: string) => {
|
||||
const traceId = randomUUID();
|
||||
])(
|
||||
"should test special S3 characters in IDs (%s)",
|
||||
async (char: string) => {
|
||||
const traceId = randomUUID();
|
||||
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: `${traceId}-${char}-test`,
|
||||
const response = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: `${traceId}-${char}-test`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({
|
||||
traceId: `${traceId}-${char}-test`,
|
||||
projectId,
|
||||
],
|
||||
});
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(`${traceId}-${char}-test`);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
expect(trace!.environment).toEqual("default");
|
||||
});
|
||||
});
|
||||
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({
|
||||
traceId: `${traceId}-${char}-test`,
|
||||
projectId,
|
||||
});
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(`${traceId}-${char}-test`);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
expect(trace!.environment).toEqual("default");
|
||||
});
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
it("should fail for \\r in id", async () => {
|
||||
const traceId = v4();
|
||||
@@ -543,6 +547,7 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
);
|
||||
});
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
it.each([
|
||||
@@ -630,6 +635,65 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("should merge metadata correctly across multiple trace updates", async () => {
|
||||
const traceId = randomUUID();
|
||||
|
||||
// First update with initial metadata: {"step": 1, "status": "started"}
|
||||
const traceUpdate1 = {
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "operation",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { step: 1, status: "started" },
|
||||
},
|
||||
};
|
||||
|
||||
const response1 = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [traceUpdate1],
|
||||
});
|
||||
expect(response1.status).toBe(207);
|
||||
|
||||
// Second update with additional metadata: {"step": 2, "error": ""}
|
||||
// This should merge with the first update
|
||||
const traceUpdate2 = {
|
||||
id: randomUUID(),
|
||||
type: "trace-create",
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(), // Later timestamp
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "operation",
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
metadata: { step: 2, error: "" },
|
||||
},
|
||||
};
|
||||
|
||||
const response2 = await makeAPICall("POST", "/api/public/ingestion", {
|
||||
batch: [traceUpdate2],
|
||||
});
|
||||
expect(response2.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({ traceId, projectId });
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(traceId);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
|
||||
// Expected final metadata: {"step": 2, "status": "started", "error": ""}
|
||||
// This verifies that:
|
||||
// - "step" is updated to the latest value (2)
|
||||
// - "status" is preserved from the first update ("started")
|
||||
// - "error" is added from the second update ("")
|
||||
expect(trace!.metadata).toEqual({
|
||||
step: 2,
|
||||
status: "started",
|
||||
error: "",
|
||||
});
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
it("#4900: should clear score comment on update with `null`", async () => {
|
||||
const scoreId = randomUUID();
|
||||
const score1 = {
|
||||
@@ -674,5 +738,5 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(score!.value).toEqual(100.5);
|
||||
expect(score!.comment).toBe(null);
|
||||
});
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,7 +118,7 @@ describe("/api/public/observations API Endpoint", () => {
|
||||
eventType: "click",
|
||||
target: "submit-button",
|
||||
});
|
||||
}, 15_000);
|
||||
}, 20_000);
|
||||
|
||||
it("should filter observations by level parameter", async () => {
|
||||
const traceId = randomUUID();
|
||||
|
||||
@@ -18,16 +18,20 @@ const ScimUserSchema = z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string(),
|
||||
userName: z.string(),
|
||||
name: z.object({
|
||||
formatted: z.string().nullable(),
|
||||
}),
|
||||
emails: z.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
name: z
|
||||
.object({
|
||||
formatted: z.string().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
emails: z
|
||||
.array(
|
||||
z.object({
|
||||
primary: z.boolean(),
|
||||
value: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
meta: z.object({
|
||||
resourceType: z.string(),
|
||||
created: z.string().optional(),
|
||||
@@ -533,6 +537,45 @@ describe("SCIM API", () => {
|
||||
expect(result.body.detail).toContain("userName is required");
|
||||
});
|
||||
|
||||
it("should create a new user with specified role", async () => {
|
||||
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ScimUserSchema,
|
||||
"POST",
|
||||
"/api/public/scim/Users",
|
||||
{
|
||||
userName: uniqueEmail,
|
||||
name: {
|
||||
formatted: "Test User With Role",
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
primary: true,
|
||||
value: uniqueEmail,
|
||||
type: "work",
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
roles: ["ADMIN"],
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
201,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.userName).toBe(uniqueEmail);
|
||||
expect(response.body.name.formatted).toBe("Test User With Role");
|
||||
|
||||
testUserId = response.body.id;
|
||||
|
||||
// Verify the user was created with the specified role
|
||||
const orgMemberships = await prisma.organizationMembership.findMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
expect(orgMemberships.length).toBe(1);
|
||||
expect(orgMemberships[0].role).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("should return 409 when user with the same userName already exists", async () => {
|
||||
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
|
||||
|
||||
@@ -617,6 +660,147 @@ describe("SCIM API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/public/scim/Users/{id}", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
const uniqueEmail = `test.user.${randomUUID().substring(0, 8)}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: uniqueEmail,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: orgId,
|
||||
role: "NONE",
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
it("should deactivate a user when active is false", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ScimUserSchema,
|
||||
"PUT",
|
||||
`/api/public/scim/Users/${testUserId}`,
|
||||
{
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: testUserId,
|
||||
userName: "test.user@example.com",
|
||||
active: false,
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(testUserId);
|
||||
|
||||
// Verify the user was removed from the organization
|
||||
const orgMemberships = await prisma.organizationMembership.findMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
expect(orgMemberships.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should reactivate a user when active is true", async () => {
|
||||
// First deactivate the user
|
||||
await prisma.organizationMembership.deleteMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ScimUserSchema,
|
||||
"PUT",
|
||||
`/api/public/scim/Users/${testUserId}`,
|
||||
{
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: testUserId,
|
||||
userName: "test.user@example.com",
|
||||
active: true,
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(testUserId);
|
||||
|
||||
// Verify the user was re-added to the organization with default role
|
||||
const orgMemberships = await prisma.organizationMembership.findMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
expect(orgMemberships.length).toBe(1);
|
||||
expect(orgMemberships[0].role).toBe("NONE");
|
||||
});
|
||||
|
||||
it("should reactivate a user with specified role", async () => {
|
||||
// First deactivate the user
|
||||
await prisma.organizationMembership.deleteMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
ScimUserSchema,
|
||||
"PUT",
|
||||
`/api/public/scim/Users/${testUserId}`,
|
||||
{
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: testUserId,
|
||||
userName: "test.user@example.com",
|
||||
active: true,
|
||||
roles: ["MEMBER"],
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(testUserId);
|
||||
|
||||
// Verify the user was re-added to the organization with specified role
|
||||
const orgMemberships = await prisma.organizationMembership.findMany({
|
||||
where: { userId: testUserId, orgId: orgId },
|
||||
});
|
||||
expect(orgMemberships.length).toBe(1);
|
||||
expect(orgMemberships[0].role).toBe("MEMBER");
|
||||
});
|
||||
|
||||
it("should return 400 when SCIM schema is missing", async () => {
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
`/api/public/scim/Users/${testUserId}`,
|
||||
{
|
||||
id: testUserId,
|
||||
userName: "test.user@example.com",
|
||||
active: false,
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.detail).toContain("schemas");
|
||||
});
|
||||
|
||||
it("should return 404 when user does not exist", async () => {
|
||||
const nonExistentUserId = randomUUID();
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
`/api/public/scim/Users/${nonExistentUserId}`,
|
||||
{
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: nonExistentUserId,
|
||||
userName: "test.user@example.com",
|
||||
active: false,
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.body.detail).toContain("User not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/public/scim/Users/{id}", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
|
||||
@@ -641,10 +641,9 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
for (const val of getAllScore.body.data) {
|
||||
expect(val).toMatchObject({
|
||||
traceId: traceId,
|
||||
trace: { tags: ["prod", "test"], userId: "user-name" },
|
||||
});
|
||||
expect(val.traceId).toBe(traceId);
|
||||
expect(val.trace?.tags?.sort()).toEqual(["prod", "test"].sort());
|
||||
expect(val.trace?.userId).toBe("user-name");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -475,10 +475,9 @@ describe("/api/public/v2/scores API Endpoint", () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
for (const val of getAllScore.body.data) {
|
||||
expect(val).toMatchObject({
|
||||
traceId: traceId,
|
||||
trace: { tags: ["prod", "test"], userId: "user-name" },
|
||||
});
|
||||
expect(val.traceId).toBe(traceId);
|
||||
expect(val.trace?.tags?.sort()).toEqual(["prod", "test"].sort());
|
||||
expect(val.trace?.userId).toBe("user-name");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
createOrgProjectAndApiKey,
|
||||
createScoresCh,
|
||||
createSessionScore,
|
||||
createTracesCh,
|
||||
getSessionsWithMetrics,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { createTrace, getSessionsTable } from "@langfuse/shared/src/server";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
|
||||
describe("trpc.sessions", () => {
|
||||
describe("GET sessions.all", () => {
|
||||
@@ -385,4 +388,52 @@ describe("trpc.sessions", () => {
|
||||
expect(sessions[0]?.trace_count).toBe(2);
|
||||
expect(parseInt(sessions[0]?.duration as any)).toBe(1);
|
||||
});
|
||||
it("should GET correct session data with filters", async () => {
|
||||
const project_id = v4();
|
||||
const trace_id_with_score = v4();
|
||||
const session_id_with_score = v4();
|
||||
const trace_id_without_score = v4();
|
||||
const session_id_without_score = v4();
|
||||
|
||||
const filterState: FilterState = [
|
||||
{
|
||||
type: "numberObject",
|
||||
column: "Scores (numeric)",
|
||||
key: "test",
|
||||
operator: ">",
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const trace_with_score = createTrace({
|
||||
id: trace_id_with_score,
|
||||
project_id,
|
||||
session_id: session_id_with_score,
|
||||
});
|
||||
const trace_without_score = createTrace({
|
||||
id: trace_id_without_score,
|
||||
project_id,
|
||||
session_id: session_id_without_score,
|
||||
});
|
||||
await createTracesCh([trace_with_score, trace_without_score]);
|
||||
|
||||
const score = createSessionScore({
|
||||
project_id,
|
||||
session_id: session_id_with_score,
|
||||
name: "test",
|
||||
value: 1,
|
||||
data_type: "NUMERIC",
|
||||
});
|
||||
await createScoresCh([score]);
|
||||
|
||||
const tableRows = await getSessionsTable({
|
||||
projectId: project_id,
|
||||
filter: filterState,
|
||||
limit: 10,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
expect(tableRows).toHaveLength(1);
|
||||
expect(tableRows[0].session_id).toEqual(session_id_with_score);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,9 +69,9 @@ describe("traces trpc", () => {
|
||||
expect(traceRes?.projectId).toEqual(projectId);
|
||||
expect(traceRes?.name).toEqual(trace.name);
|
||||
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
|
||||
expect(traceRes?.tags).toEqual(trace.tags);
|
||||
expect(traceRes?.input).toEqual(trace.input);
|
||||
expect(traceRes?.output).toEqual(trace.output);
|
||||
expect(traceRes?.tags?.sort()).toEqual(trace.tags?.sort());
|
||||
expect(traceRes?.input).toBeNull();
|
||||
expect(traceRes?.output).toBeNull();
|
||||
expect(traceRes?.userId).toEqual(trace.user_id);
|
||||
expect(traceRes?.sessionId).toEqual(trace.session_id);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("Traces table API test", () => {
|
||||
expect(tableRows).toHaveLength(1);
|
||||
expect(tableRows[0].id).toEqual(trace_id);
|
||||
expect(tableRows[0].projectId).toEqual(project_id);
|
||||
expect(tableRows[0].tags).toEqual(trace.tags);
|
||||
expect(tableRows[0].tags.sort()).toEqual(trace.tags.sort());
|
||||
expect(tableRows[0].name).toEqual(trace.name);
|
||||
expect(tableRows[0].bookmarked).toEqual(trace.bookmarked);
|
||||
expect(tableRows[0].release).toEqual(trace.release);
|
||||
@@ -98,7 +98,7 @@ describe("Traces table API test", () => {
|
||||
expect(tableRows).toHaveLength(1);
|
||||
expect(tableRows[0].id).toEqual(trace_id);
|
||||
expect(tableRows[0].projectId).toEqual(project_id);
|
||||
expect(tableRows[0].tags).toEqual(trace.tags);
|
||||
expect(tableRows[0].tags.sort()).toEqual(trace.tags.sort());
|
||||
expect(tableRows[0].name).toEqual(trace.name);
|
||||
expect(tableRows[0].bookmarked).toEqual(trace.bookmarked);
|
||||
expect(tableRows[0].release).toEqual(trace.release);
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
createObservationsCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
import {
|
||||
transformToPivotTable,
|
||||
type DatabaseRow,
|
||||
} from "@/src/features/widgets/utils/pivot-table-utils";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
import { executeQuery } from "@/src/features/query/server/queryExecutor";
|
||||
|
||||
describe("Dashboard Router - Pivot Table Integration", () => {
|
||||
// Single project ID for all tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clickhouseClient } from "@langfuse/shared/src/server";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/query/server/queryExecutor";
|
||||
import {
|
||||
createTrace,
|
||||
createObservation,
|
||||
@@ -238,20 +238,7 @@ describe("queryBuilder", () => {
|
||||
async (_name, query: QueryType) => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
// When
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Then
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result = await executeQuery(projectId, query);
|
||||
expect(result).toBeDefined();
|
||||
},
|
||||
);
|
||||
@@ -373,17 +360,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -420,17 +398,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -483,17 +452,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return traces with name "qa-bot"
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -529,17 +489,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -597,17 +548,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(4);
|
||||
// Expect one entry for all so index order does not matter
|
||||
@@ -662,17 +604,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return traces with tag-a or tag-b
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -742,17 +675,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return traces with both tag-a and tag-b
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -813,17 +737,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return traces without tag-a or tag-b
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -890,17 +805,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -957,17 +863,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(4); // 2 names × 2 environments = 4 combinations
|
||||
@@ -1030,17 +927,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -1083,20 +971,14 @@ describe("queryBuilder", () => {
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause is present in the query
|
||||
expect(compiledQuery).toContain("ORDER BY name asc");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - results should be ordered by name alphabetically
|
||||
expect(result.data).toHaveLength(3);
|
||||
@@ -1134,20 +1016,14 @@ describe("queryBuilder", () => {
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause is present in the query
|
||||
expect(compiledQuery).toContain("ORDER BY sum_observationsCount desc");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - results should be ordered by observation count descending
|
||||
expect(result.data).toHaveLength(3);
|
||||
@@ -1187,25 +1063,17 @@ describe("queryBuilder", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause is present in the query with both fields
|
||||
expect(compiledQuery).toContain(
|
||||
"ORDER BY environment asc, sum_observationsCount desc",
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
// Assert - results should be ordered by environment (asc) and then by observation count (desc)
|
||||
expect(result.data).toHaveLength(4);
|
||||
|
||||
@@ -1263,22 +1131,15 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause includes default time dimension ordering
|
||||
expect(compiledQuery).toContain("ORDER BY time_dimension asc");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Results should be ordered by time dimension (ascending)
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
@@ -1323,22 +1184,15 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause includes default metric ordering (descending)
|
||||
expect(compiledQuery).toContain("ORDER BY sum_observationsCount desc");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Results should be ordered by observation count descending (default for metrics)
|
||||
expect(result.data).toHaveLength(3);
|
||||
@@ -1374,22 +1228,15 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify ORDER BY clause includes default dimension ordering (ascending)
|
||||
expect(compiledQuery).toContain("ORDER BY name asc");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Results should be ordered by name ascending (default for dimensions)
|
||||
expect(result.data).toHaveLength(3);
|
||||
@@ -1433,17 +1280,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -1480,17 +1318,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -1560,23 +1389,16 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify WITH FILL clause is present in the query
|
||||
expect(compiledQuery).toContain("WITH FILL");
|
||||
expect(compiledQuery).toContain("STEP INTERVAL 1 DAY");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Expected to have 3 days in the result (including the filled gap)
|
||||
expect(result.data.length).toBeGreaterThanOrEqual(3);
|
||||
@@ -1713,17 +1535,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should have 5 combinations (chat-today, chat-yesterday, embeddings-today, embeddings-yesterday, embeddings-dayBefore)
|
||||
expect(result.data).toHaveLength(5);
|
||||
@@ -1831,22 +1644,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient({
|
||||
clickhouse_settings: {
|
||||
date_time_output_format: "iso",
|
||||
},
|
||||
}).query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Verify we have results
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
@@ -1926,17 +1725,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe("trace-with-metadata-1");
|
||||
@@ -2074,7 +1864,6 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
@@ -2087,12 +1876,9 @@ describe("queryBuilder", () => {
|
||||
expect(compiledQuery).toContain(": String}) = 0");
|
||||
expect(Object.values(parameters)).toContain("CATEGORICAL");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(3); // accuracy, relevance, coherence
|
||||
@@ -2199,17 +1985,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return human scores
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -2307,23 +2084,16 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify joins included
|
||||
expect(compiledQuery).toContain("LEFT JOIN traces");
|
||||
expect(compiledQuery).toContain("LEFT JOIN observations");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should have 2 rows (1 for each trace/model combination)
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -2441,17 +2211,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return true scores
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -2536,17 +2297,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe("score-premium");
|
||||
@@ -2607,22 +2359,15 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const { query: compiledQuery } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify the compiled query contains filtering on name
|
||||
expect(compiledQuery).toContain("scores_numeric.name");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return scores with name "accuracy"
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -2744,7 +2489,6 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
@@ -2755,12 +2499,9 @@ describe("queryBuilder", () => {
|
||||
expect(compiledQuery).toContain("data_type = {");
|
||||
expect(Object.values(parameters)).toContain("CATEGORICAL");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should have 4 rows for different name+value combinations
|
||||
expect(result.data).toHaveLength(4);
|
||||
@@ -2865,17 +2606,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert - should only return auto-source scores
|
||||
expect(result.data).toHaveLength(2);
|
||||
@@ -3026,18 +2758,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Assert
|
||||
expect(result.data).toHaveLength(3);
|
||||
@@ -3133,18 +2855,9 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
@@ -3206,18 +2919,9 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
@@ -3281,18 +2985,9 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
@@ -3366,17 +3061,8 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].name).toBe("observation-basic");
|
||||
@@ -3463,7 +3149,7 @@ describe("queryBuilder", () => {
|
||||
const queryBuilder = new QueryBuilder(
|
||||
customBinHistogramQuery.chartConfig,
|
||||
);
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
const { query: compiledQuery } = queryBuilder.build(
|
||||
customBinHistogramQuery,
|
||||
projectId,
|
||||
);
|
||||
@@ -3472,12 +3158,9 @@ describe("queryBuilder", () => {
|
||||
expect(compiledQuery).toContain("histogram(20)");
|
||||
expect(compiledQuery).toContain("total_cost");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, customBinHistogramQuery);
|
||||
|
||||
// Assert histogram results with custom bins
|
||||
expect(result.data).toHaveLength(1);
|
||||
@@ -3545,17 +3228,9 @@ describe("queryBuilder", () => {
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
// Execute query
|
||||
const result: { data: Array<any> } = { data: [] };
|
||||
result.data = await executeQuery(projectId, query);
|
||||
|
||||
// Verify the month is formatted as YYYY-MM
|
||||
expect(result.data).toHaveLength(1);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createObservationsCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
import { executeQuery } from "@/src/features/query/server/queryExecutor";
|
||||
|
||||
describe("selfServeDashboards", () => {
|
||||
// Single project ID for all tests
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { executeQuery } from "@/src/features/query/server/queryExecutor";
|
||||
import { InvalidRequestError } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Test suite for testing SQL injection vulnerabilities in the QueryBuilder
|
||||
@@ -596,7 +596,7 @@ describe("QueryBuilder SQL Injection Tests", () => {
|
||||
// Expect the executeQuery function to throw a TRPC error
|
||||
// rather than allowing the injection
|
||||
await expect(executeQuery(projectId, maliciousQuery)).rejects.toThrow(
|
||||
TRPCError,
|
||||
InvalidRequestError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,19 @@ export default function Layout(props: PropsWithChildren) {
|
||||
// project info based on projectId in the URL
|
||||
const { project, organization } = useQueryProjectOrOrganization();
|
||||
|
||||
// Helper function for precise path matching
|
||||
const isPathActive = (routePath: string, currentPath: string): boolean => {
|
||||
// Exact match
|
||||
if (currentPath === routePath) return true;
|
||||
|
||||
// Only allow prefix matching if the route ends with a specific page (not just project root)
|
||||
// This prevents /project/123 from matching /project/123/datasets
|
||||
const isRoot = routePath.split("/").length <= 3;
|
||||
if (isRoot) return false;
|
||||
|
||||
return currentPath.startsWith(routePath + "/");
|
||||
};
|
||||
|
||||
const mapNavigation = (route: Route): NavigationItem | null => {
|
||||
// Project-level routes
|
||||
if (!routerProjectId && route.pathname.includes("[projectId]")) return null;
|
||||
@@ -196,7 +209,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
return {
|
||||
...route,
|
||||
url: url,
|
||||
isActive: router.pathname === route.pathname,
|
||||
isActive: isPathActive(route.pathname, router.pathname),
|
||||
items:
|
||||
items.length > 0
|
||||
? (items as NavigationItem[]) // does not include null due to filter
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { memo } from "react";
|
||||
import { type Row } from "@tanstack/react-table";
|
||||
import { urlRegex } from "@langfuse/shared";
|
||||
import { type JsonTableRow } from "@/src/components/table/utils/jsonExpansionUtils";
|
||||
|
||||
const MAX_STRING_LENGTH_FOR_LINK_DETECTION = 1500;
|
||||
const MAX_CELL_DISPLAY_CHARS = 2000;
|
||||
const SMALL_ARRAY_THRESHOLD = 5;
|
||||
const ARRAY_PREVIEW_ITEMS = 3;
|
||||
const OBJECT_PREVIEW_KEYS = 2;
|
||||
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
|
||||
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
|
||||
|
||||
function renderStringWithLinks(text: string): React.ReactNode {
|
||||
if (text.length >= MAX_STRING_LENGTH_FOR_LINK_DETECTION) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const localUrlRegex = new RegExp(urlRegex.source, "gi");
|
||||
const parts = text.split(localUrlRegex);
|
||||
const matches = text.match(localUrlRegex) || [];
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
let matchIndex = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i]) {
|
||||
result.push(parts[i]);
|
||||
}
|
||||
|
||||
if (matchIndex < matches.length) {
|
||||
const url = matches[matchIndex];
|
||||
result.push(
|
||||
<a
|
||||
key={`link-${matchIndex}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:opacity-80"
|
||||
onClick={(e) => e.stopPropagation()} // no row expansion when clicking links
|
||||
>
|
||||
{url}
|
||||
</a>,
|
||||
);
|
||||
matchIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getValueType(value: unknown): JsonTableRow["type"] {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value as JsonTableRow["type"];
|
||||
}
|
||||
|
||||
function renderArrayValue(arr: unknown[]): JSX.Element {
|
||||
if (arr.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
|
||||
}
|
||||
|
||||
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
|
||||
// Show inline values for small arrays
|
||||
const displayItems = arr
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" && item !== null) {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return "{}";
|
||||
if (keys.length <= OBJECT_PREVIEW_KEYS) {
|
||||
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
|
||||
return `{${keyPreview}}`;
|
||||
} else {
|
||||
return `{"${keys[0]}": ...}`;
|
||||
}
|
||||
}
|
||||
if (itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
|
||||
} else {
|
||||
// Show truncated values for large arrays
|
||||
const preview = arr
|
||||
.slice(0, ARRAY_PREVIEW_ITEMS)
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" || itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return (
|
||||
<span className={PREVIEW_TEXT_CLASSES}>
|
||||
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
|
||||
}
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
|
||||
}
|
||||
|
||||
function getValueStringLength(value: unknown): number {
|
||||
if (typeof value === "string") {
|
||||
return value.length;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return String(value).length;
|
||||
}
|
||||
}
|
||||
|
||||
function getTruncatedValue(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const truncated = value.substring(0, maxChars);
|
||||
const lastSpaceIndex = truncated.lastIndexOf(" ");
|
||||
|
||||
// Try to truncate at word boundary if possible
|
||||
if (lastSpaceIndex > maxChars * 0.8) {
|
||||
return truncated.substring(0, lastSpaceIndex) + "...";
|
||||
}
|
||||
|
||||
return truncated + "...";
|
||||
}
|
||||
|
||||
export const ValueCell = memo(
|
||||
({
|
||||
row,
|
||||
expandedCells,
|
||||
toggleCellExpansion,
|
||||
}: {
|
||||
row: Row<JsonTableRow>;
|
||||
expandedCells: Set<string>;
|
||||
toggleCellExpansion: (cellId: string) => void;
|
||||
}) => {
|
||||
const { value, type } = row.original;
|
||||
const cellId = `${row.id}-value`;
|
||||
const isCellExpanded = expandedCells.has(cellId);
|
||||
|
||||
const getDisplayValue = () => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
const stringValue = String(value);
|
||||
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
|
||||
const displayValue =
|
||||
needsTruncation && !isCellExpanded
|
||||
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
|
||||
: stringValue;
|
||||
|
||||
return {
|
||||
content: (
|
||||
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
|
||||
"{renderStringWithLinks(displayValue)}"
|
||||
</span>
|
||||
),
|
||||
needsTruncation,
|
||||
};
|
||||
}
|
||||
case "number":
|
||||
return {
|
||||
content: (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
),
|
||||
needsTruncation: false,
|
||||
};
|
||||
case "boolean":
|
||||
return {
|
||||
content: (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
),
|
||||
needsTruncation: false,
|
||||
};
|
||||
case "null":
|
||||
return {
|
||||
content: (
|
||||
<span className="italic text-gray-500 dark:text-gray-400">
|
||||
null
|
||||
</span>
|
||||
),
|
||||
needsTruncation: false,
|
||||
};
|
||||
case "undefined":
|
||||
return {
|
||||
content: (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
undefined
|
||||
</span>
|
||||
),
|
||||
needsTruncation: false,
|
||||
};
|
||||
case "array": {
|
||||
const arrayValue = value as unknown[];
|
||||
// Arrays always show previews, never truncate
|
||||
return {
|
||||
content: renderArrayValue(arrayValue),
|
||||
needsTruncation: false,
|
||||
};
|
||||
}
|
||||
case "object": {
|
||||
const objectValue = value as Record<string, unknown>;
|
||||
// Objects always show previews, never truncate
|
||||
return {
|
||||
content: renderObjectValue(objectValue),
|
||||
needsTruncation: false,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const stringValue = String(value);
|
||||
const needsTruncation = stringValue.length > MAX_CELL_DISPLAY_CHARS;
|
||||
const displayValue =
|
||||
needsTruncation && !isCellExpanded
|
||||
? getTruncatedValue(stringValue, MAX_CELL_DISPLAY_CHARS)
|
||||
: stringValue;
|
||||
|
||||
return {
|
||||
content: (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{displayValue}
|
||||
</span>
|
||||
),
|
||||
needsTruncation,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { content, needsTruncation } = getDisplayValue();
|
||||
|
||||
return (
|
||||
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>
|
||||
{content}
|
||||
{needsTruncation && !row.original.hasChildren && (
|
||||
<div
|
||||
className="inline cursor-pointer opacity-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCellExpansion(cellId);
|
||||
}}
|
||||
>
|
||||
{isCellExpanded
|
||||
? "\n...collapse"
|
||||
: `\n...expand (${getValueStringLength(value) - MAX_CELL_DISPLAY_CHARS} more characters)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ValueCell.displayName = "ValueCell";
|
||||
|
||||
// Export utilities that might be needed elsewhere
|
||||
export { getValueStringLength };
|
||||
@@ -67,6 +67,7 @@ interface DataTableProps<TData, TValue> {
|
||||
pinFirstColumn?: boolean;
|
||||
hidePagination?: boolean;
|
||||
tableName: string;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -123,6 +124,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
pinFirstColumn = false,
|
||||
hidePagination = false,
|
||||
tableName,
|
||||
getRowClassName,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
|
||||
@@ -381,6 +383,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
help={help}
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
pinFirstColumn={pinFirstColumn}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
@@ -430,6 +433,7 @@ interface TableBodyComponentProps<TData> {
|
||||
help?: { description: string; href: string };
|
||||
onRowClick?: (row: TData) => void;
|
||||
pinFirstColumn?: boolean;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
tableSnapshot?: {
|
||||
tableDataUpdatedAt?: number;
|
||||
columnVisibility?: VisibilityState;
|
||||
@@ -441,10 +445,12 @@ interface TableBodyComponentProps<TData> {
|
||||
function TableRowComponent<TData>({
|
||||
row,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
children,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
onRowClick?: (row: TData) => void;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -462,6 +468,7 @@ function TableRowComponent<TData>({
|
||||
"hover:bg-accent",
|
||||
!!onRowClick ? "cursor-pointer" : "cursor-default",
|
||||
selectedRowId && selectedRowId === row.id ? "bg-accent" : undefined,
|
||||
getRowClassName?.(row.original),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -477,6 +484,7 @@ function TableBodyComponent<TData>({
|
||||
help,
|
||||
onRowClick,
|
||||
pinFirstColumn = false,
|
||||
getRowClassName,
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
@@ -491,7 +499,12 @@ function TableBodyComponent<TData>({
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRowComponent key={row.id} row={row} onRowClick={onRowClick}>
|
||||
<TableRowComponent
|
||||
key={row.id}
|
||||
row={row}
|
||||
onRowClick={onRowClick}
|
||||
getRowClassName={getRowClassName}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
||||
@@ -14,6 +14,12 @@ import { LangfuseIcon } from "@/src/components/LangfuseLogo";
|
||||
import { UserCircle2Icon } from "lucide-react";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import { DeactivateEvalConfig } from "@/src/features/evals/components/deactivate-config";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { api } from "@/src/utils/api";
|
||||
|
||||
export const PeekViewEvaluatorConfigDetail = ({
|
||||
projectId,
|
||||
@@ -23,12 +29,16 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
row?: EvaluatorDataRow;
|
||||
}) => {
|
||||
const { peekId } = usePeekState();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: evalConfig } = usePeekEvalConfigData({
|
||||
jobConfigurationId: peekId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const hasAccess = useHasProjectAccess({ projectId, scope: "evalJob:CUD" });
|
||||
|
||||
if (!evalConfig) {
|
||||
return <Skeleton className="h-full w-full" />;
|
||||
}
|
||||
@@ -36,8 +46,8 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
return (
|
||||
<div className="grid h-full flex-1 grid-rows-[auto,auto,1fr] gap-2 overflow-hidden p-3 contain-layout">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="max-h-fit text-lg font-medium">Configuration</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="max-h-fit text-lg font-medium">Configuration</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
type={evalConfig.status.toLowerCase()}
|
||||
@@ -49,7 +59,22 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
evalConfig={evalConfig}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">View Only</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn("text-sm", isEditMode ? "" : "text-muted-foreground")}
|
||||
>
|
||||
Edit Mode
|
||||
</span>
|
||||
<Switch
|
||||
disabled={
|
||||
!hasAccess ||
|
||||
(evalConfig?.timeScope?.length === 1 &&
|
||||
evalConfig.timeScope[0] === "EXISTING")
|
||||
}
|
||||
checked={isEditMode}
|
||||
onCheckedChange={setIsEditMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="flex items-center text-sm">
|
||||
@@ -76,7 +101,7 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
</CardDescription>
|
||||
<div className="flex max-h-full w-full flex-col items-start justify-between space-y-2 overflow-y-auto pb-4">
|
||||
<EvaluatorForm
|
||||
key={evalConfig?.id}
|
||||
key={`${evalConfig?.id}-${evalConfig?.updatedAt}-${isEditMode}`}
|
||||
projectId={projectId}
|
||||
evalTemplates={
|
||||
evalConfig?.evalTemplate ? [evalConfig.evalTemplate] : []
|
||||
@@ -89,9 +114,18 @@ export const PeekViewEvaluatorConfigDetail = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={true}
|
||||
mode="edit"
|
||||
disabled={!isEditMode}
|
||||
shouldWrapVariables={true}
|
||||
useDialog={false}
|
||||
onFormSuccess={() => {
|
||||
setIsEditMode(false);
|
||||
utils.evals.invalidate();
|
||||
showSuccessToast({
|
||||
title: "Running Evaluator updated",
|
||||
description: "The evaluator configuration has been updated.",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import useColumnVisibility from "@/src/features/column-visibility/hooks/useColum
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type Prisma } from "@langfuse/shared/src/db";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { IOTableCell } from "../../ui/IOTableCell";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TokenUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
BatchExportTableName,
|
||||
type ObservationType,
|
||||
TableViewPresetTableName,
|
||||
AnnotationQueueObjectType,
|
||||
BatchActionType,
|
||||
} from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { LevelColors } from "@/src/components/level-colors";
|
||||
@@ -22,7 +24,7 @@ import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { observationsTableColsWithOptions } from "@langfuse/shared";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { MemoizedIOTableCell } from "../../ui/IOTableCell";
|
||||
import {
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
@@ -43,7 +45,7 @@ import {
|
||||
convertSelectedEnvironmentsToFilter,
|
||||
} from "@/src/hooks/use-environment-filter";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { type Row } from "@tanstack/react-table";
|
||||
import { type RowSelectionState, type Row } from "@tanstack/react-table";
|
||||
import TableIdOrName from "@/src/components/table/table-id";
|
||||
import { ItemBadge } from "@/src/components/ItemBadge";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
@@ -55,6 +57,11 @@ import { useTableViewManager } from "@/src/components/table/table-view-presets/h
|
||||
import { useRouter } from "next/router";
|
||||
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
|
||||
import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekView";
|
||||
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
|
||||
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
|
||||
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
|
||||
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
|
||||
import { type TableAction } from "@/src/features/table/types";
|
||||
|
||||
export type ObservationsTableRow = {
|
||||
// Shown by default
|
||||
@@ -117,10 +124,12 @@ export default function ObservationsTable({
|
||||
const { viewId } = router.query;
|
||||
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
const { searchQuery, searchType, setSearchQuery, setSearchType } =
|
||||
useFullTextSearch();
|
||||
|
||||
const { selectAll, setSelectAll } = useSelectAll(projectId, "observations");
|
||||
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
@@ -272,6 +281,19 @@ export default function ObservationsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
showSuccessToast({
|
||||
title: "Observations added to queue",
|
||||
description: `Selected observations will be added to queue "${data.queueName}". This may take a minute.`,
|
||||
link: {
|
||||
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
|
||||
text: `View queue "${data.queueName}"`,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (generations.isSuccess) {
|
||||
setDetailPageList(
|
||||
@@ -302,7 +324,54 @@ export default function ObservationsTable({
|
||||
);
|
||||
};
|
||||
|
||||
const { selectActionColumn } = TableSelectionManager<ObservationsTableRow>({
|
||||
projectId,
|
||||
tableName: "observations",
|
||||
setSelectedRows,
|
||||
});
|
||||
|
||||
const handleAddToAnnotationQueue = async ({
|
||||
projectId,
|
||||
targetId,
|
||||
}: {
|
||||
projectId: string;
|
||||
targetId: string;
|
||||
}) => {
|
||||
const selectedGenerationIds = Object.keys(selectedRows).filter(
|
||||
(generationId) =>
|
||||
generations.data?.generations.map((g) => g.id).includes(generationId),
|
||||
);
|
||||
|
||||
await addToQueueMutation.mutateAsync({
|
||||
projectId,
|
||||
objectIds: selectedGenerationIds,
|
||||
objectType: AnnotationQueueObjectType.OBSERVATION,
|
||||
queueId: targetId,
|
||||
isBatchAction: selectAll,
|
||||
query: {
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
},
|
||||
});
|
||||
setSelectedRows({});
|
||||
};
|
||||
|
||||
const tableActions: TableAction[] = [
|
||||
{
|
||||
id: "observation-add-to-annotation-queue",
|
||||
type: BatchActionType.Create,
|
||||
label: "Add to Annotation Queue",
|
||||
description: "Add selected observations to an annotation queue.",
|
||||
targetLabel: "Annotation Queue",
|
||||
execute: handleAddToAnnotationQueue,
|
||||
accessCheck: {
|
||||
scope: "annotationQueues:CUD",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: LangfuseColumnDef<ObservationsTableRow>[] = [
|
||||
selectActionColumn,
|
||||
{
|
||||
accessorKey: "startTime",
|
||||
id: "startTime",
|
||||
@@ -975,7 +1044,7 @@ export default function ObservationsTable({
|
||||
setRowHeight={setRowHeight}
|
||||
selectedOption={selectedOption}
|
||||
setDateRangeAndOption={setDateRangeAndOption}
|
||||
actionButtons={
|
||||
actionButtons={[
|
||||
<BatchExportTableButton
|
||||
{...{
|
||||
projectId,
|
||||
@@ -986,13 +1055,37 @@ export default function ObservationsTable({
|
||||
}}
|
||||
tableName={BatchExportTableName.Observations}
|
||||
key="batchExport"
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
Object.keys(selectedRows).filter((generationId) =>
|
||||
generations.data?.generations
|
||||
.map((g) => g.id)
|
||||
.includes(generationId),
|
||||
).length > 0 ? (
|
||||
<TableActionMenu
|
||||
key="observations-multi-select-actions"
|
||||
projectId={projectId}
|
||||
actions={tableActions}
|
||||
tableName={BatchExportTableName.Observations}
|
||||
/>
|
||||
) : null,
|
||||
]}
|
||||
environmentFilter={{
|
||||
values: selectedEnvironments,
|
||||
onValueChange: setSelectedEnvironments,
|
||||
options: environmentOptions.map((env) => ({ value: env })),
|
||||
}}
|
||||
multiSelect={{
|
||||
selectAll,
|
||||
setSelectAll,
|
||||
selectedRowIds: Object.keys(selectedRows).filter((generationId) =>
|
||||
generations.data?.generations
|
||||
.map((g) => g.id)
|
||||
.includes(generationId),
|
||||
),
|
||||
setRowSelection: setSelectedRows,
|
||||
totalCount,
|
||||
...paginationState,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
tableName={"observations"}
|
||||
@@ -1018,6 +1111,8 @@ export default function ObservationsTable({
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
rowSelection={selectedRows}
|
||||
setRowSelection={setSelectedRows}
|
||||
setOrderBy={setOrderByState}
|
||||
orderBy={orderByState}
|
||||
columnOrder={columnOrder}
|
||||
@@ -1051,6 +1146,7 @@ const GenerationsDynamicCell = ({
|
||||
traceId,
|
||||
projectId,
|
||||
startTime,
|
||||
truncated: true,
|
||||
},
|
||||
{
|
||||
enabled: typeof traceId === "string" && typeof observationId === "string",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Prisma,
|
||||
type ConfigCategory,
|
||||
} from "@langfuse/shared";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { IOTableCell } from "../../ui/IOTableCell";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import {
|
||||
isBooleanDataType,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { IOTableCell } from "../../ui/IOTableCell";
|
||||
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
TableViewPresetTableName,
|
||||
} from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { MemoizedIOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { MemoizedIOTableCell } from "../../ui/IOTableCell";
|
||||
import {
|
||||
getScoreGroupColumnProps,
|
||||
verifyAndPrefixScoreDataAgainstKeys,
|
||||
@@ -134,6 +134,7 @@ export type TracesTableProps = {
|
||||
hideControls?: boolean;
|
||||
externalFilterState?: FilterState;
|
||||
externalDateRange?: TableDateRange;
|
||||
limitRows?: number;
|
||||
};
|
||||
|
||||
export default function TracesTable({
|
||||
@@ -143,6 +144,7 @@ export default function TracesTable({
|
||||
hideControls = false,
|
||||
externalFilterState,
|
||||
externalDateRange,
|
||||
limitRows,
|
||||
}: TracesTableProps) {
|
||||
const utils = api.useUtils();
|
||||
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
|
||||
@@ -246,8 +248,8 @@ export default function TracesTable({
|
||||
...tracesAllCountFilter,
|
||||
searchQuery: searchQuery,
|
||||
searchType: searchType,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
page: limitRows ? 0 : paginationState.pageIndex,
|
||||
limit: limitRows ?? paginationState.pageSize,
|
||||
orderBy: orderByState,
|
||||
};
|
||||
|
||||
@@ -451,10 +453,10 @@ export default function TracesTable({
|
||||
const enableSorting = !hideControls;
|
||||
|
||||
const columns: LangfuseColumnDef<TracesTableRow>[] = [
|
||||
selectActionColumn,
|
||||
...(hideControls
|
||||
? []
|
||||
: [
|
||||
selectActionColumn,
|
||||
{
|
||||
accessorKey: "bookmarked",
|
||||
header: undefined,
|
||||
@@ -996,12 +998,12 @@ export default function TracesTable({
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useColumnVisibility<TracesTableRow>(
|
||||
`traceColumnVisibility-${projectId}${hideControls ? "-hideControls" : "-showControls"}`,
|
||||
`traceColumnVisibility-${projectId}${hideControls ? "-hideControl" : "-showControls"}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useColumnOrder<TracesTableRow>(
|
||||
`traceColumnOrder-${projectId}${hideControls ? "-hideControls" : "-showControls"}`,
|
||||
`traceColumnOrder-${projectId}${hideControls ? "-hideControl" : "-showControls"}`,
|
||||
columns,
|
||||
);
|
||||
|
||||
@@ -1193,11 +1195,15 @@ export default function TracesTable({
|
||||
data: rows,
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
pagination={
|
||||
limitRows
|
||||
? undefined
|
||||
: {
|
||||
totalCount,
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}
|
||||
}
|
||||
setOrderBy={setOrderByState}
|
||||
orderBy={orderByState}
|
||||
rowSelection={selectedRows}
|
||||
@@ -1229,7 +1235,7 @@ const TracesDynamicCell = ({
|
||||
singleLine?: boolean;
|
||||
}) => {
|
||||
const trace = api.traces.byId.useQuery(
|
||||
{ traceId, projectId, timestamp },
|
||||
{ traceId, projectId, timestamp, truncated: true },
|
||||
{
|
||||
refetchOnMount: false, // prevents refetching loops
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
|
||||
@@ -13,6 +13,10 @@ export function getRowChildren(row: JsonTableRow): JsonTableRow[] {
|
||||
return row.subRows;
|
||||
}
|
||||
if (row.rawChildData) {
|
||||
// Prevent infinite recursion by limiting depth; 25 levels of nesting should make a reasonable assumption
|
||||
if (row.level > 25) {
|
||||
return [];
|
||||
}
|
||||
return transformJsonToTableData(
|
||||
row.rawChildData,
|
||||
row.key,
|
||||
|
||||
@@ -171,6 +171,12 @@ const OtherSection = ({ details, isCost, formatValue }: OtherSectionProps) => {
|
||||
|
||||
if (otherEntries.length === 0) return null;
|
||||
|
||||
const otherTotal = otherEntries.reduce((acc, val) => {
|
||||
if (typeof val[1] !== "number") return acc;
|
||||
|
||||
return acc + (val[1] ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
@@ -178,7 +184,7 @@ const OtherSection = ({ details, isCost, formatValue }: OtherSectionProps) => {
|
||||
{isCost ? "Other cost" : "Other usage"}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-medium">
|
||||
{formatValue(details.total ?? 0)}
|
||||
{formatValue(otherTotal)}
|
||||
</span>
|
||||
</div>
|
||||
{otherEntries.map(([key, value]) => (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MarkdownJsonView } from "@/src/components/ui/MarkdownJsonView";
|
||||
import { SubHeaderLabel } from "@/src/components/layouts/header";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
|
||||
export const IOPreview: React.FC<{
|
||||
input?: Prisma.JsonValue;
|
||||
@@ -47,9 +48,9 @@ export const IOPreview: React.FC<{
|
||||
onOutputExpansionChange,
|
||||
...props
|
||||
}) => {
|
||||
const [localCurrentView, setLocalCurrentView] = useState<"pretty" | "json">(
|
||||
"pretty",
|
||||
);
|
||||
const [localCurrentView, setLocalCurrentView] = useLocalStorage<
|
||||
"pretty" | "json"
|
||||
>("jsonViewPreference", "pretty");
|
||||
const selectedView = currentView ?? localCurrentView;
|
||||
const capture = usePostHogClientCapture();
|
||||
const input = deepParseJson(props.input);
|
||||
|
||||
@@ -59,7 +59,10 @@ export const ObservationPreview = ({
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
const [currentView, setCurrentView] = useState<"pretty" | "json">("pretty");
|
||||
const [currentView, setCurrentView] = useLocalStorage<"pretty" | "json">(
|
||||
"jsonViewPreference",
|
||||
"pretty",
|
||||
);
|
||||
const capture = usePostHogClientCapture();
|
||||
const [isPrettyViewAvailable, setIsPrettyViewAvailable] = useState(false);
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
|
||||
@@ -60,7 +60,10 @@ export const TracePreview = ({
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
const [currentView, setCurrentView] = useState<"pretty" | "json">("pretty");
|
||||
const [currentView, setCurrentView] = useLocalStorage<"pretty" | "json">(
|
||||
"jsonViewPreference",
|
||||
"pretty",
|
||||
);
|
||||
const [isPrettyViewAvailable, setIsPrettyViewAvailable] = useState(false);
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Check,
|
||||
@@ -22,7 +22,7 @@ import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
|
||||
import { renderContentWithPromptButtons } from "@/src/features/prompts/components/renderContentWithPromptButtons";
|
||||
import { copyTextToClipboard } from "@/src/utils/clipboard";
|
||||
|
||||
const IO_TABLE_CHAR_LIMIT = 10000;
|
||||
export const IO_TABLE_CHAR_LIMIT = 10000;
|
||||
|
||||
export function JSONView(props: {
|
||||
canEnableMarkdown?: boolean;
|
||||
@@ -292,71 +292,6 @@ export function CodeView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export const IOTableCell = ({
|
||||
data,
|
||||
isLoading = false,
|
||||
className,
|
||||
singleLine = false,
|
||||
}: {
|
||||
data: unknown;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
singleLine?: boolean;
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />;
|
||||
}
|
||||
|
||||
const stringifiedJson =
|
||||
data !== null && data !== undefined ? stringifyJsonNode(data) : undefined;
|
||||
|
||||
// perf: truncate to IO_TABLE_CHAR_LIMIT characters as table becomes unresponsive attempting to render large JSONs with high levels of nesting
|
||||
const shouldTruncate =
|
||||
stringifiedJson && stringifiedJson.length > IO_TABLE_CHAR_LIMIT;
|
||||
|
||||
return (
|
||||
<>
|
||||
{singleLine ? (
|
||||
<div
|
||||
className={cn(
|
||||
"ph-no-capture h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stringifiedJson}
|
||||
</div>
|
||||
) : shouldTruncate ? (
|
||||
<div className="ph-no-capture grid h-full grid-cols-1">
|
||||
<JSONView
|
||||
json={
|
||||
stringifiedJson.slice(0, IO_TABLE_CHAR_LIMIT) +
|
||||
`...[truncated ${stringifiedJson.length - IO_TABLE_CHAR_LIMIT} characters]`
|
||||
}
|
||||
className={cn("h-full w-full self-stretch rounded-sm", className)}
|
||||
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
|
||||
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Content was truncated.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
json={data}
|
||||
className={cn(
|
||||
"ph-no-capture h-full w-full self-stretch rounded-sm",
|
||||
className,
|
||||
)}
|
||||
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
|
||||
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemoizedIOTableCell = memo(IOTableCell);
|
||||
|
||||
export const JsonSkeleton = ({
|
||||
className,
|
||||
numRows = 10,
|
||||
@@ -382,7 +317,7 @@ export const JsonSkeleton = ({
|
||||
};
|
||||
|
||||
// TODO: deduplicate with PrettyJsonView.tsx
|
||||
function stringifyJsonNode(node: unknown) {
|
||||
export function stringifyJsonNode(node: unknown) {
|
||||
// return single string nodes without quotes
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
JsonSkeleton,
|
||||
stringifyJsonNode,
|
||||
IO_TABLE_CHAR_LIMIT,
|
||||
JSONView,
|
||||
} from "@/src/components/ui/CodeJsonViewer";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { memo } from "react";
|
||||
|
||||
export const IOTableCell = ({
|
||||
data,
|
||||
isLoading = false,
|
||||
className,
|
||||
singleLine = false,
|
||||
}: {
|
||||
data: unknown;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
singleLine?: boolean;
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />;
|
||||
}
|
||||
|
||||
const stringifiedJson =
|
||||
data !== null && data !== undefined ? stringifyJsonNode(data) : undefined;
|
||||
|
||||
// perf: truncate to IO_TABLE_CHAR_LIMIT characters as table becomes unresponsive attempting to render large JSONs with high levels of nesting
|
||||
const shouldTruncate =
|
||||
stringifiedJson && stringifiedJson.length > IO_TABLE_CHAR_LIMIT;
|
||||
|
||||
return (
|
||||
<>
|
||||
{singleLine ? (
|
||||
<div
|
||||
className={cn(
|
||||
"ph-no-capture h-full w-full self-stretch overflow-hidden overflow-y-auto truncate rounded-sm border px-2 py-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stringifiedJson}
|
||||
</div>
|
||||
) : shouldTruncate ? (
|
||||
<div className="ph-no-capture grid h-full grid-cols-1">
|
||||
<JSONView
|
||||
json={
|
||||
stringifiedJson.slice(0, IO_TABLE_CHAR_LIMIT) +
|
||||
`...[truncated ${stringifiedJson.length - IO_TABLE_CHAR_LIMIT} characters]`
|
||||
}
|
||||
className={cn("h-full w-full self-stretch rounded-sm", className)}
|
||||
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
|
||||
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Content was truncated.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
json={data}
|
||||
className={cn(
|
||||
"ph-no-capture h-full w-full self-stretch rounded-sm",
|
||||
className,
|
||||
)}
|
||||
codeClassName="py-1 px-2 min-h-0 h-full overflow-y-auto"
|
||||
collapseStringsAfterLength={null} // in table, show full strings as row height is fixed
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const MemoizedIOTableCell = memo(IOTableCell);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState, useEffect, memo, useRef, useCallback } from "react";
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { deepParseJson, urlRegex } from "@langfuse/shared";
|
||||
import { deepParseJson } from "@langfuse/shared";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { type MediaReturnType } from "@/src/features/media/validation";
|
||||
import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
|
||||
@@ -46,11 +46,10 @@ import {
|
||||
type JsonTableRow,
|
||||
transformJsonToTableData,
|
||||
} from "@/src/components/table/utils/jsonExpansionUtils";
|
||||
|
||||
// Constants for array/object preview logic
|
||||
const SMALL_ARRAY_THRESHOLD = 5;
|
||||
const ARRAY_PREVIEW_ITEMS = 3;
|
||||
const OBJECT_PREVIEW_KEYS = 2;
|
||||
import {
|
||||
ValueCell,
|
||||
getValueStringLength,
|
||||
} from "@/src/components/table/ValueCell";
|
||||
|
||||
// Constants for table layout
|
||||
const INDENTATION_PER_LEVEL = 16;
|
||||
@@ -63,7 +62,7 @@ const CELL_PADDING_X = 8; // px-2
|
||||
const DEFAULT_MAX_ROWS = 20;
|
||||
const DEEPEST_DEFAULT_EXPANSION_LEVEL = 10;
|
||||
|
||||
const MAX_STRING_LENGTH_FOR_LINK_DETECTION = 1500;
|
||||
const MAX_CELL_DISPLAY_CHARS = 2000;
|
||||
|
||||
const ASSISTANT_TITLES = ["assistant", "Output", "model"];
|
||||
const SYSTEM_TITLES = ["system", "Input"];
|
||||
@@ -71,44 +70,6 @@ const SYSTEM_TITLES = ["system", "Input"];
|
||||
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
|
||||
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
|
||||
|
||||
function renderStringWithLinks(text: string): React.ReactNode {
|
||||
if (text.length >= MAX_STRING_LENGTH_FOR_LINK_DETECTION) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const localUrlRegex = new RegExp(urlRegex.source, "gi");
|
||||
const parts = text.split(localUrlRegex);
|
||||
const matches = text.match(localUrlRegex) || [];
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
let matchIndex = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i]) {
|
||||
result.push(parts[i]);
|
||||
}
|
||||
|
||||
if (matchIndex < matches.length) {
|
||||
const url = matches[matchIndex];
|
||||
result.push(
|
||||
<a
|
||||
key={`link-${matchIndex}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:opacity-80"
|
||||
onClick={(e) => e.stopPropagation()} // no row expansion when clicking links
|
||||
>
|
||||
{url}
|
||||
</a>,
|
||||
);
|
||||
matchIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getEmptyValueDisplay(value: unknown): string | null {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
@@ -146,7 +107,16 @@ function isChatMLFormat(json: unknown): boolean {
|
||||
|
||||
if (Array.isArray(json)) {
|
||||
const directArray = ChatMlArraySchema.safeParse(json);
|
||||
if (directArray.success) return true;
|
||||
if (directArray.success) {
|
||||
// had some false positives, so we really check for role/content to validate ChatML
|
||||
const hasRoleOrContent = json.some(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
("role" in item || "content" in item),
|
||||
);
|
||||
return hasRoleOrContent;
|
||||
}
|
||||
}
|
||||
|
||||
if ("messages" in json && Array.isArray((json as any).messages)) {
|
||||
@@ -255,6 +225,7 @@ function findOptimalExpansionLevel(
|
||||
rows: JsonTableRow[],
|
||||
currentLevel: number,
|
||||
cumulativeCount: number,
|
||||
visitedData = new WeakSet(),
|
||||
): number {
|
||||
const rowsAtThisLevel = rows.length;
|
||||
const newCumulativeCount = cumulativeCount + rowsAtThisLevel;
|
||||
@@ -270,8 +241,21 @@ function findOptimalExpansionLevel(
|
||||
|
||||
// Get all children for next level
|
||||
const childRows: JsonTableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.hasChildren) {
|
||||
if (row.hasChildren && row.rawChildData) {
|
||||
if (typeof row.rawChildData !== "object" || row.rawChildData === null) {
|
||||
continue; // Skip non-objects
|
||||
}
|
||||
|
||||
// Skip if we've already processed this exact data to prevent cycles
|
||||
if (visitedData.has(row.rawChildData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark data as visited
|
||||
visitedData.add(row.rawChildData);
|
||||
|
||||
const children = getRowChildren(row);
|
||||
childRows.push(...children);
|
||||
}
|
||||
@@ -285,129 +269,37 @@ function findOptimalExpansionLevel(
|
||||
childRows,
|
||||
currentLevel + 1,
|
||||
newCumulativeCount,
|
||||
visitedData,
|
||||
);
|
||||
}
|
||||
|
||||
return Math.max(0, findOptimalRecursively(data, 0, 0));
|
||||
}
|
||||
|
||||
function renderArrayValue(arr: unknown[]): JSX.Element {
|
||||
if (arr.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
|
||||
}
|
||||
|
||||
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
|
||||
// Show inline values for small arrays
|
||||
const displayItems = arr
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" && item !== null) {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return "{}";
|
||||
if (keys.length <= OBJECT_PREVIEW_KEYS) {
|
||||
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
|
||||
return `{${keyPreview}}`;
|
||||
} else {
|
||||
return `{"${keys[0]}": ...}`;
|
||||
}
|
||||
}
|
||||
if (itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
|
||||
} else {
|
||||
// Show truncated values for large arrays
|
||||
const preview = arr
|
||||
.slice(0, ARRAY_PREVIEW_ITEMS)
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" || itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return (
|
||||
<span className={PREVIEW_TEXT_CLASSES}>
|
||||
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
|
||||
}
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
|
||||
}
|
||||
|
||||
const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
|
||||
const { value, type } = row.original;
|
||||
|
||||
const renderValue = () => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
const stringValue = String(value);
|
||||
return (
|
||||
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
|
||||
"{renderStringWithLinks(stringValue)}"
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "number":
|
||||
return (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "null":
|
||||
return (
|
||||
<span className="italic text-gray-500 dark:text-gray-400">null</span>
|
||||
);
|
||||
case "undefined":
|
||||
return (
|
||||
<span className="text-gray-500 dark:text-gray-400">undefined</span>
|
||||
);
|
||||
case "array":
|
||||
return renderArrayValue(value as unknown[]);
|
||||
case "object":
|
||||
return renderObjectValue(value as Record<string, unknown>);
|
||||
default:
|
||||
return (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>{renderValue()}</div>
|
||||
);
|
||||
});
|
||||
|
||||
ValueCell.displayName = "ValueCell";
|
||||
|
||||
function handleRowExpansion(
|
||||
row: Row<JsonTableRow>,
|
||||
onLazyLoadChildren?: (rowId: string) => void,
|
||||
expandedCells?: Set<string>,
|
||||
toggleCellExpansion?: (cellId: string) => void,
|
||||
) {
|
||||
// row expansion takes precedence over cell expansion
|
||||
if (row.original.hasChildren) {
|
||||
const originalRow = row.original;
|
||||
if (originalRow.rawChildData && !originalRow.childrenGenerated) {
|
||||
onLazyLoadChildren?.(originalRow.id);
|
||||
}
|
||||
row.toggleExpanded();
|
||||
return;
|
||||
}
|
||||
|
||||
// does the row have children, then expand row
|
||||
const cellId = `${row.id}-value`;
|
||||
const { value } = row.original;
|
||||
const valueStringLength = getValueStringLength(value);
|
||||
const needsCellExpansion = valueStringLength > MAX_CELL_DISPLAY_CHARS;
|
||||
|
||||
if (needsCellExpansion && expandedCells && toggleCellExpansion) {
|
||||
toggleCellExpansion(cellId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +313,8 @@ function JsonPrettyTable({
|
||||
onLazyLoadChildren,
|
||||
onForceUpdate,
|
||||
smartDefaultsLevel,
|
||||
expandedCells,
|
||||
toggleCellExpansion,
|
||||
}: {
|
||||
data: JsonTableRow[];
|
||||
expandAllRef?: React.MutableRefObject<(() => void) | null>;
|
||||
@@ -433,6 +327,8 @@ function JsonPrettyTable({
|
||||
onLazyLoadChildren?: (rowId: string) => void;
|
||||
onForceUpdate?: () => void;
|
||||
smartDefaultsLevel?: number | null;
|
||||
expandedCells: Set<string>;
|
||||
toggleCellExpansion: (cellId: string) => void;
|
||||
}) {
|
||||
const columns: LangfuseColumnDef<JsonTableRow, unknown>[] = [
|
||||
{
|
||||
@@ -459,7 +355,12 @@ function JsonPrettyTable({
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowExpansion(row, onLazyLoadChildren);
|
||||
handleRowExpansion(
|
||||
row,
|
||||
onLazyLoadChildren,
|
||||
expandedCells,
|
||||
toggleCellExpansion,
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
@@ -485,7 +386,13 @@ function JsonPrettyTable({
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
size: 65,
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
cell: ({ row }) => (
|
||||
<ValueCell
|
||||
row={row}
|
||||
expandedCells={expandedCells}
|
||||
toggleCellExpansion={toggleCellExpansion}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -627,8 +534,24 @@ function JsonPrettyTable({
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => handleRowExpansion(row, onLazyLoadChildren)}
|
||||
className={row.original.hasChildren ? "cursor-pointer" : ""}
|
||||
onClick={() =>
|
||||
handleRowExpansion(
|
||||
row,
|
||||
onLazyLoadChildren,
|
||||
expandedCells,
|
||||
toggleCellExpansion,
|
||||
)
|
||||
}
|
||||
className={
|
||||
row.original.hasChildren ||
|
||||
(!row.original.hasChildren &&
|
||||
row.original.type !== "array" &&
|
||||
row.original.type !== "object" &&
|
||||
getValueStringLength(row.original.value) >
|
||||
MAX_CELL_DISPLAY_CHARS)
|
||||
? "cursor-pointer"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
@@ -682,6 +605,7 @@ export function PrettyJsonView(props: {
|
||||
const [expandedRowsWithChildren, setExpandedRowsWithChildren] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [expandedCells, setExpandedCells] = useState<Set<string>>(new Set());
|
||||
const [, setForceUpdate] = useState(0);
|
||||
|
||||
// View's own state, lower precedence than optionally supplied external expansion state
|
||||
@@ -888,6 +812,18 @@ export function PrettyJsonView(props: {
|
||||
setForceUpdate((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const toggleCellExpansion = useCallback((cellId: string) => {
|
||||
setExpandedCells((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(cellId)) {
|
||||
newSet.delete(cellId);
|
||||
} else {
|
||||
newSet.add(cellId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { onExternalExpansionChange } = props;
|
||||
const handleTableExpandedChange = useCallback(
|
||||
(
|
||||
@@ -1030,6 +966,8 @@ export function PrettyJsonView(props: {
|
||||
onLazyLoadChildren={handleLazyLoadChildren}
|
||||
onForceUpdate={handleForceUpdate}
|
||||
smartDefaultsLevel={null}
|
||||
expandedCells={expandedCells}
|
||||
toggleCellExpansion={toggleCellExpansion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Search, X, MoreHorizontal } from "lucide-react";
|
||||
|
||||
interface MultiSelectComboboxProps<T> {
|
||||
selectedItems: T[];
|
||||
onItemsChange: (items: T[]) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
searchResults: T[];
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
hasMoreResults?: boolean;
|
||||
renderItem: (
|
||||
item: T,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void,
|
||||
) => React.ReactNode;
|
||||
renderSelectedItem: (item: T, onRemove: () => void) => React.ReactNode;
|
||||
getItemKey: (item: T) => string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MultiSelectCombobox<T>({
|
||||
selectedItems,
|
||||
onItemsChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
searchResults,
|
||||
isLoading = false,
|
||||
placeholder = "Search...",
|
||||
hasMoreResults = false,
|
||||
renderItem,
|
||||
renderSelectedItem,
|
||||
getItemKey,
|
||||
disabled = false,
|
||||
}: MultiSelectComboboxProps<T>) {
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [previousResults, setPreviousResults] = useState<T[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle focus/blur for dropdown visibility
|
||||
const handleInputFocus = () => {
|
||||
setIsInputFocused(true);
|
||||
setShowDropdown(true);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
setIsInputFocused(false);
|
||||
// Delay hiding dropdown to allow clicking on dropdown items
|
||||
setTimeout(() => {
|
||||
if (!isInputFocused) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Auto-scroll to input when items are added/removed
|
||||
useEffect(() => {
|
||||
if (inputRef.current && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [selectedItems.length]);
|
||||
|
||||
// Update previous results when new data arrives (not loading)
|
||||
useEffect(() => {
|
||||
if (!isLoading && searchResults.length > 0) {
|
||||
setPreviousResults(searchResults);
|
||||
}
|
||||
}, [isLoading, searchResults]);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
containerRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
setIsInputFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showDropdown) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleItemToggle = (item: T) => {
|
||||
const itemKey = getItemKey(item);
|
||||
const isSelected = selectedItems.some(
|
||||
(selected) => getItemKey(selected) === itemKey,
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
onItemsChange(
|
||||
selectedItems.filter((selected) => getItemKey(selected) !== itemKey),
|
||||
);
|
||||
} else {
|
||||
onItemsChange([...selectedItems, item]);
|
||||
}
|
||||
|
||||
// Keep dropdown open after item toggle
|
||||
setShowDropdown(true);
|
||||
setIsInputFocused(true);
|
||||
};
|
||||
|
||||
const handleItemRemove = (item: T) => {
|
||||
const itemKey = getItemKey(item);
|
||||
onItemsChange(
|
||||
selectedItems.filter((selected) => getItemKey(selected) !== itemKey),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Custom Input with Embedded Pills */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex max-h-14 min-h-9 w-full overflow-y-auto rounded-md border border-input bg-background text-xs"
|
||||
>
|
||||
<Search className="absolute left-2 top-2.5 z-10 h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex max-h-full flex-1 flex-wrap items-center gap-1 pl-8">
|
||||
{/* Selected Items Pills */}
|
||||
{selectedItems.map((item) => (
|
||||
<div key={getItemKey(item)}>
|
||||
{renderSelectedItem(item, () => handleItemRemove(item))}
|
||||
</div>
|
||||
))}
|
||||
{/* Search Input */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={selectedItems.length === 0 ? placeholder : ""}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={disabled}
|
||||
className="min-w-24 flex-1 border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-1 h-7 w-7 p-0"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{showDropdown && (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{searchResults.length > 0 ||
|
||||
(isLoading && previousResults.length > 0) ? (
|
||||
<div
|
||||
className="absolute top-0 z-10 max-h-48 w-full overflow-y-auto rounded-md border bg-background shadow-md"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{(isLoading && previousResults.length > 0
|
||||
? previousResults
|
||||
: searchResults
|
||||
).map((item, index, array) => (
|
||||
<div key={getItemKey(item)}>
|
||||
{renderItem(
|
||||
item,
|
||||
selectedItems.some(
|
||||
(selected) => getItemKey(selected) === getItemKey(item),
|
||||
),
|
||||
() => handleItemToggle(item),
|
||||
)}
|
||||
{(index < array.length - 1 || hasMoreResults) && (
|
||||
<div className="border-b border-border/50" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMoreResults && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs italic">
|
||||
More results available, refine your search
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-0 z-10 w-full rounded-md border bg-background py-6 text-center text-xs text-muted-foreground shadow-md">
|
||||
{searchQuery
|
||||
? `No results found for "${searchQuery}"`
|
||||
: "No results available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.89.0";
|
||||
export const VERSION = "v3.95.2";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { IOTableCell } from "@/src/components/ui/IOTableCell";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
|
||||
@@ -72,7 +72,9 @@ export const OktaProviderSchema = base.extend({
|
||||
.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
issuer: z.string(),
|
||||
issuer: z.string().startsWith("https://", {
|
||||
message: "Okta issuer must start with https://",
|
||||
}),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
|
||||
+2
-2
@@ -249,7 +249,7 @@ export const env = createEnv({
|
||||
LANGFUSE_INIT_ORG_CLOUD_PLAN: z.string().optional(), // for use in CI
|
||||
LANGFUSE_INIT_PROJECT_ID: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_NAME: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_RETENTION: z.number().int().gte(3).optional(),
|
||||
LANGFUSE_INIT_PROJECT_RETENTION: z.coerce.number().int().gte(3).optional(),
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: z.string().optional(),
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: z.string().optional(),
|
||||
LANGFUSE_INIT_USER_EMAIL: z
|
||||
@@ -257,7 +257,7 @@ export const env = createEnv({
|
||||
.optional(),
|
||||
LANGFUSE_INIT_USER_NAME: z.string().optional(),
|
||||
LANGFUSE_INIT_USER_PASSWORD: z.string().optional(),
|
||||
LANGFUSE_MAX_HISTORIC_EVAL_CREATION_LIMIT: z
|
||||
LANGFUSE_MAX_HISTORIC_EVAL_CREATION_LIMIT: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(50_000),
|
||||
|
||||
@@ -34,6 +34,7 @@ type RowData = {
|
||||
countPendingItems: number;
|
||||
scoreConfigs: { id: string; name: string; dataType: ScoreDataType }[];
|
||||
createdAt: string;
|
||||
isAssigned: boolean;
|
||||
};
|
||||
|
||||
export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
|
||||
@@ -213,6 +214,7 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
|
||||
createdAt: item.createdAt.toLocaleString(),
|
||||
countCompletedItems: item.countCompletedItems,
|
||||
countPendingItems: item.countPendingItems,
|
||||
isAssigned: item.isCurrentUserAssigned,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -265,6 +267,9 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
getRowClassName={(row) =>
|
||||
row.isAssigned ? "bg-primary/5 border-l-4 border-l-primary/40" : ""
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+145
-37
@@ -26,8 +26,8 @@ import { useForm } from "react-hook-form";
|
||||
import { Form } from "@/src/components/ui/form";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import {
|
||||
type CreateQueue,
|
||||
CreateQueueData,
|
||||
type CreateQueueWithAssignments,
|
||||
CreateQueueWithAssignmentsData,
|
||||
type ValidatedScoreConfig,
|
||||
} from "@langfuse/shared";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -39,6 +39,14 @@ import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { DropdownMenuItem } from "@/src/components/ui/dropdown-menu";
|
||||
import { useUniqueNameValidation } from "@/src/hooks/useUniqueNameValidation";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/src/components/ui/collapsible";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { UserAssignmentSection } from "@/src/features/annotation-queues/components/UserAssignmentSection";
|
||||
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
|
||||
|
||||
export const CreateOrEditAnnotationQueueButton = ({
|
||||
projectId,
|
||||
@@ -52,21 +60,26 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
size?: ButtonProps["size"];
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasAccess = useHasProjectAccess({
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const hasQueueAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
const hasQueueAssignmentsReadAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "annotationQueueAssignments:read",
|
||||
});
|
||||
const queueLimit = useEntitlementLimit("annotation-queue-count");
|
||||
const router = useRouter();
|
||||
const capture = usePostHogClientCapture();
|
||||
|
||||
const queueQuery = api.annotationQueues.byId.useQuery(
|
||||
{ projectId, queueId: queueId as string },
|
||||
{ enabled: !!queueId && hasAccess },
|
||||
{ enabled: !!queueId && hasQueueAccess },
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateQueueData),
|
||||
resolver: zodResolver(CreateQueueWithAssignmentsData),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,11 +90,13 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
scoreConfigIds: queueQuery.data.scoreConfigs.map(
|
||||
(config: ValidatedScoreConfig) => config.id,
|
||||
),
|
||||
newAssignmentUserIds: [],
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: "",
|
||||
scoreConfigIds: [],
|
||||
newAssignmentUserIds: [],
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -89,24 +104,14 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createQueueMutation = api.annotationQueues.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([utils.annotationQueues.invalidate()]);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
},
|
||||
});
|
||||
const editQueueMutation = api.annotationQueues.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([utils.annotationQueues.invalidate()]);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
},
|
||||
});
|
||||
const createQueueMutation = api.annotationQueues.create.useMutation();
|
||||
const editQueueMutation = api.annotationQueues.update.useMutation();
|
||||
const createQueueAssignmentsMutation =
|
||||
api.annotationQueueAssignments.createMany.useMutation();
|
||||
|
||||
const queueCountData = api.annotationQueues.count.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: hasAccess },
|
||||
{ enabled: hasQueueAccess },
|
||||
);
|
||||
|
||||
const configsData = api.scoreConfigs.all.useQuery(
|
||||
@@ -114,13 +119,13 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: hasAccess && isOpen,
|
||||
enabled: hasQueueAccess && isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const allQueueNamesAndIds = api.annotationQueues.allNamesAndIds.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: hasAccess && !queueId },
|
||||
{ enabled: hasQueueAccess && !queueId },
|
||||
);
|
||||
|
||||
const allQueueNames = useMemo(() => {
|
||||
@@ -138,18 +143,54 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
|
||||
const configs = configsData.data?.configs ?? [];
|
||||
|
||||
const onSubmit = (data: CreateQueue) => {
|
||||
if (queueId) {
|
||||
editQueueMutation.mutateAsync({
|
||||
...data,
|
||||
projectId,
|
||||
queueId,
|
||||
});
|
||||
} else {
|
||||
createQueueMutation.mutateAsync({
|
||||
...data,
|
||||
projectId,
|
||||
});
|
||||
const onSubmit = async (data: CreateQueueWithAssignments) => {
|
||||
try {
|
||||
// Step 1: Create or update the queue
|
||||
let queueResponse;
|
||||
if (queueId) {
|
||||
// Update existing queue
|
||||
queueResponse = await editQueueMutation.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
scoreConfigIds: data.scoreConfigIds,
|
||||
projectId,
|
||||
queueId,
|
||||
});
|
||||
} else {
|
||||
// Create new queue
|
||||
queueResponse = await createQueueMutation.mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
scoreConfigIds: data.scoreConfigIds,
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Handle assignment if provided
|
||||
if (data.newAssignmentUserIds && data.newAssignmentUserIds.length > 0) {
|
||||
const targetQueueId = queueId || queueResponse.id;
|
||||
|
||||
await createQueueAssignmentsMutation.mutateAsync({
|
||||
projectId,
|
||||
queueId: targetQueueId,
|
||||
userIds: data.newAssignmentUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Success handling
|
||||
await Promise.all([
|
||||
utils.annotationQueues.invalidate(),
|
||||
utils.annotationQueueAssignments.invalidate(),
|
||||
]);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
|
||||
// capture posthog event
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
"Operation failed",
|
||||
"Failed to create or update queue or assign users. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +224,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
hasAccess={hasAccess}
|
||||
hasAccess={hasQueueAccess}
|
||||
limitValue={queueCountData.data}
|
||||
limit={queueLimit}
|
||||
size={size}
|
||||
@@ -258,6 +299,7 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
<MultiSelectKeyValues
|
||||
placeholder="Value"
|
||||
align="end"
|
||||
variant="outline"
|
||||
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
|
||||
onValueChange={handleOnValueChange}
|
||||
options={configs
|
||||
@@ -299,14 +341,80 @@ export const CreateOrEditAnnotationQueueButton = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newAssignmentUserIds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Advanced Settings</FormLabel>
|
||||
<div className="mt-1 rounded-md border">
|
||||
<Collapsible
|
||||
open={isAdvancedOpen && hasQueueAssignmentsReadAccess}
|
||||
onOpenChange={(open) => {
|
||||
if (!hasQueueAssignmentsReadAccess) {
|
||||
setIsAdvancedOpen(false);
|
||||
} else {
|
||||
setIsAdvancedOpen(open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="group flex w-full items-center justify-between px-3 py-2.5 text-left hover:bg-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdvancedOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
User Assignment
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-border/20 px-3 pb-3 pt-1">
|
||||
{hasQueueAssignmentsReadAccess && (
|
||||
<>
|
||||
<FormControl>
|
||||
<UserAssignmentSection
|
||||
projectId={projectId}
|
||||
queueId={queueId}
|
||||
selectedUserIds={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="text-xs"
|
||||
disabled={!!form.formState.errors.name}
|
||||
disabled={
|
||||
!!form.formState.errors.name ||
|
||||
createQueueMutation.isLoading ||
|
||||
editQueueMutation.isLoading ||
|
||||
createQueueAssignmentsMutation.isLoading
|
||||
}
|
||||
>
|
||||
{queueId ? "Save" : "Create"} queue
|
||||
{createQueueMutation.isLoading ||
|
||||
editQueueMutation.isLoading ||
|
||||
createQueueAssignmentsMutation.isLoading
|
||||
? "Processing..."
|
||||
: `${queueId ? "Save" : "Create"} queue`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { MoreHorizontal, X } from "lucide-react";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { MultiSelectCombobox } from "@/src/components/ui/multi-select-combobox";
|
||||
import { useUserSearch } from "@/src/features/annotation-queues/hooks/useUserSearch";
|
||||
import { useSelectedUsers } from "@/src/features/annotation-queues/hooks/useSelectedUsers";
|
||||
|
||||
interface UserAssignmentSectionProps {
|
||||
projectId: string;
|
||||
selectedUserIds: string[];
|
||||
onChange: (userIds: string[]) => void;
|
||||
queueId?: string;
|
||||
}
|
||||
|
||||
export const UserAssignmentSection = ({
|
||||
projectId,
|
||||
selectedUserIds,
|
||||
onChange,
|
||||
queueId,
|
||||
}: UserAssignmentSectionProps) => {
|
||||
const hasQueueAssignmentsReadAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "annotationQueueAssignments:read",
|
||||
});
|
||||
const hasQueueAssignmentWriteAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "annotationQueueAssignments:CUD",
|
||||
});
|
||||
|
||||
// Get current assigned users
|
||||
const queueAssignmentsQuery =
|
||||
api.annotationQueueAssignments.byQueueId.useQuery(
|
||||
{ projectId, queueId: queueId as string },
|
||||
{ enabled: !!queueId && hasQueueAssignmentsReadAccess },
|
||||
);
|
||||
|
||||
// Combine selected users and assigned users for exclusion
|
||||
const assignedUserIds =
|
||||
queueAssignmentsQuery.data?.assignments.map((user: any) => user.id) || [];
|
||||
const excludeUserIds = [...new Set([...selectedUserIds, ...assignedUserIds])];
|
||||
|
||||
const userSearch = useUserSearch({
|
||||
projectId,
|
||||
excludeUserIds,
|
||||
});
|
||||
|
||||
const { selectedUsers } = useSelectedUsers({
|
||||
projectId,
|
||||
selectedUserIds,
|
||||
});
|
||||
|
||||
// Handle user selection changes
|
||||
const handleUsersChange = (users: typeof userSearch.searchResults) => {
|
||||
const userIds = users.map((user) => user.id);
|
||||
onChange(userIds);
|
||||
};
|
||||
|
||||
// Check if there are more assigned users than shown
|
||||
const hasMoreAssignedUsers =
|
||||
queueAssignmentsQuery.data &&
|
||||
queueAssignmentsQuery.data.totalCount >
|
||||
queueAssignmentsQuery.data.assignments.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* User Selection Combobox */}
|
||||
<MultiSelectCombobox
|
||||
selectedItems={selectedUsers}
|
||||
onItemsChange={handleUsersChange}
|
||||
searchQuery={userSearch.searchQuery}
|
||||
onSearchChange={userSearch.setSearchQuery}
|
||||
searchResults={userSearch.searchResults}
|
||||
isLoading={userSearch.isLoading}
|
||||
disabled={!hasQueueAssignmentWriteAccess}
|
||||
placeholder="Search users to add..."
|
||||
hasMoreResults={userSearch.hasMoreResults}
|
||||
getItemKey={(user) => user.id}
|
||||
renderSelectedItem={(user, onRemove) => (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs">
|
||||
<span className="max-w-32 truncate">{user.name || user.email}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-muted-foreground/20"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
renderItem={(user, isSelected, onToggle) => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-muted/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{user.name || "Unnamed User"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="text-xs text-muted-foreground">✓</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Assigned Users Section */}
|
||||
{queueAssignmentsQuery.data &&
|
||||
queueAssignmentsQuery.data?.totalCount > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm text-muted-foreground">
|
||||
Assigned to ({queueAssignmentsQuery.data?.totalCount})
|
||||
</h4>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border bg-background">
|
||||
{queueAssignmentsQuery.data?.assignments.map(
|
||||
(user: any, index: number) => (
|
||||
<div key={user.id}>
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{user.name || "Unnamed User"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Button variant="ghost" size="icon-sm">
|
||||
<X className="h-3 w-3" />
|
||||
</Button> */}
|
||||
</div>
|
||||
{(index <
|
||||
queueAssignmentsQuery.data?.assignments.length - 1 ||
|
||||
hasMoreAssignedUsers) && (
|
||||
<div className="border-b border-border/50" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{hasMoreAssignedUsers && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs italic">
|
||||
{queueAssignmentsQuery.data.totalCount -
|
||||
queueAssignmentsQuery.data.assignments.length}{" "}
|
||||
more assigned users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
interface UseSelectedUsersProps {
|
||||
projectId: string;
|
||||
selectedUserIds: string[];
|
||||
}
|
||||
|
||||
export function useSelectedUsers({
|
||||
projectId,
|
||||
selectedUserIds,
|
||||
}: UseSelectedUsersProps) {
|
||||
const hasProjectMembersReadAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "projectMembers:read",
|
||||
});
|
||||
|
||||
// Get all users without search filter to maintain selected users data
|
||||
const allUsersForPills = api.members.byProjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: hasProjectMembersReadAccess && selectedUserIds.length > 0,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedUsers =
|
||||
allUsersForPills.data?.users.filter((user) =>
|
||||
selectedUserIds.includes(user.id),
|
||||
) || [];
|
||||
|
||||
return {
|
||||
selectedUsers,
|
||||
isLoading: allUsersForPills.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
interface UseUserSearchProps {
|
||||
projectId: string;
|
||||
excludeUserIds?: string[];
|
||||
}
|
||||
|
||||
export function useUserSearch({
|
||||
projectId,
|
||||
excludeUserIds,
|
||||
}: UseUserSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||
|
||||
const hasProjectMembersReadAccess = useHasProjectAccess({
|
||||
projectId: projectId,
|
||||
scope: "projectMembers:read",
|
||||
});
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Get search results
|
||||
const searchResults = api.members.byProjectId.useQuery(
|
||||
{
|
||||
projectId,
|
||||
searchQuery: debouncedSearchQuery || undefined,
|
||||
page: 0,
|
||||
limit: 50,
|
||||
excludeUserIds:
|
||||
excludeUserIds && excludeUserIds.length > 0
|
||||
? excludeUserIds
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
enabled: hasProjectMembersReadAccess,
|
||||
},
|
||||
);
|
||||
|
||||
const hasMoreResults =
|
||||
searchResults.data &&
|
||||
searchResults.data.totalCount > searchResults.data.users.length;
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults: searchResults.data?.users || [],
|
||||
isLoading: searchResults.isLoading,
|
||||
hasMoreResults: hasMoreResults || false,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user