Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
534f5696ad | ||
|
|
4cebe2831c | ||
|
|
45a5f3b8a2 | ||
|
|
8dea9b6a3a | ||
|
|
c5b781d3f6 | ||
|
|
07469c928d | ||
|
|
5b10110dfe | ||
|
|
0eb5d1c3c0 | ||
|
|
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 | ||
|
|
bb9d118853 | ||
|
|
df57ff60d6 | ||
|
|
bae2c5a65d | ||
|
|
3efa696513 | ||
|
|
58ebf006b8 | ||
|
|
16743363dc | ||
|
|
ebf0e35073 | ||
|
|
2c4799340f | ||
|
|
5612c6a9a5 | ||
|
|
b62962ca08 | ||
|
|
3a46283bf2 | ||
|
|
07801180f8 | ||
|
|
14831902ef | ||
|
|
d377f02a49 | ||
|
|
e07120b163 | ||
|
|
506482dbe4 | ||
|
|
ec61f4a420 | ||
|
|
4db08a2960 | ||
|
|
2351d0d370 | ||
|
|
99b3401549 | ||
|
|
6fb796df40 | ||
|
|
846d6e6c37 | ||
|
|
1fa4fc6329 | ||
|
|
a1a48d5e7b | ||
|
|
fcb7563763 | ||
|
|
ae754b146d | ||
|
|
49343b9a0c | ||
|
|
76bf5c0f4e | ||
|
|
e91a29be61 | ||
|
|
81694363a2 | ||
|
|
5998266680 | ||
|
|
462e8e847d | ||
|
|
d7c186858b | ||
|
|
e686aedac9 | ||
|
|
85f75a5e8e | ||
|
|
1ea643300d | ||
|
|
596486c1ec | ||
|
|
cb65391c2d | ||
|
|
419e07260d | ||
|
|
825f030e81 | ||
|
|
34ded9c927 | ||
|
|
c935d4ae73 | ||
|
|
e7283ac06e | ||
|
|
20d0106626 | ||
|
|
849591fdf2 | ||
|
|
0c7b50e564 | ||
|
|
37b4f43351 | ||
|
|
7a3b0379e5 | ||
|
|
f458d7626c | ||
|
|
258dde4691 | ||
|
|
f15246d9df | ||
|
|
edb43a24ab | ||
|
|
c45ae1b140 | ||
|
|
90eeeeaf9d | ||
|
|
66accb563c | ||
|
|
9f2e8906d0 | ||
|
|
c5d61b7ed2 | ||
|
|
f95dd872e6 | ||
|
|
04339741a9 | ||
|
|
9c85e46996 | ||
|
|
4a7236451f | ||
|
|
5cb5204181 | ||
|
|
c19066afa0 | ||
|
|
bbb9dc285d | ||
|
|
b750acba06 | ||
|
|
bb06e079eb | ||
|
|
5f711780e0 | ||
|
|
0f58ea1ebf |
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse-development",
|
||||
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
|
||||
"name": "langfuse-development",
|
||||
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
|
||||
"onCreateCommand": "npm install -g pnpm@9.5.0",
|
||||
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -83,3 +83,8 @@ NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
# For SDK integration tests to pass, decrease the ingestion queue delay by uncommenting the env vars:
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=10
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=10
|
||||
|
||||
# Slack credentials for development
|
||||
SLACK_CLIENT_ID=your_slack_client_id
|
||||
SLACK_CLIENT_SECRET=your_slack_client_secret
|
||||
SLACK_STATE_SECRET=your_slack_state_secret
|
||||
|
||||
@@ -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,43 +0,0 @@
|
||||
name: Trivy Container Security
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
trivy-web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Run Trivy vulnerability scanner (web)
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: "langfuse/langfuse"
|
||||
format: "sarif"
|
||||
output: "trivy-web.sarif"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab (web)
|
||||
if: always() && hashFiles('trivy-web.sarif') != ''
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: "trivy-web.sarif"
|
||||
category: trivy-web
|
||||
|
||||
trivy-worker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Run Trivy vulnerability scanner (worker)
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: "langfuse/langfuse-worker"
|
||||
format: "sarif"
|
||||
output: "trivy-worker.sarif"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab (worker)
|
||||
if: always() && hashFiles('trivy-worker.sarif') != ''
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: "trivy-worker.sarif"
|
||||
category: trivy-worker
|
||||
+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
|
||||
|
||||
@@ -192,3 +192,6 @@ To get a project, use the `get_project` capability with the full project name as
|
||||
|
||||
## General Coding Guidelines
|
||||
- For easier code reviews, prefer not to move functions etc around within a file unless necessary or instructed to do so
|
||||
|
||||
## Development Tips
|
||||
- Before trying to build the package, try running the linter once first
|
||||
+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:-}
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"dependencies": {
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"axios": "^1.8.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.30",
|
||||
"next-auth": "^4.24.11",
|
||||
|
||||
@@ -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/
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.85.2",
|
||||
"version": "3.96.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -40,7 +40,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.6.2",
|
||||
"release-it": "^19.0.3",
|
||||
"turbo": "^2.5.4"
|
||||
"turbo": "^2.5.5"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^2.5.4",
|
||||
"eslint-config-turbo": "^2.5.5",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE traces ON CLUSTER default DROP INDEX IF EXISTS idx_user_id;
|
||||
ALTER TABLE traces DROP INDEX IF EXISTS idx_user_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;
|
||||
@@ -61,7 +61,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.675.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.679.0",
|
||||
"@azure/storage-blob": "^12.26.0",
|
||||
"@clickhouse/client": "^1.11.2",
|
||||
"@clickhouse/client": "^1.12.0",
|
||||
"@google-cloud/storage": "^7.15.2",
|
||||
"@langchain/anthropic": "^0.3.22",
|
||||
"@langchain/aws": "^0.1.11",
|
||||
@@ -73,8 +73,9 @@
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@react-email/components": "^0.1.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@slack/oauth": "^3.0.3",
|
||||
"@slack/web-api": "^7.9.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
"dd-trace": "^5.36.0",
|
||||
|
||||
@@ -55,6 +55,7 @@ export type AnnotationQueueStatus =
|
||||
export const AnnotationQueueObjectType = {
|
||||
TRACE: "TRACE",
|
||||
OBSERVATION: "OBSERVATION",
|
||||
SESSION: "SESSION",
|
||||
} as const;
|
||||
export type AnnotationQueueObjectType =
|
||||
(typeof AnnotationQueueObjectType)[keyof typeof AnnotationQueueObjectType];
|
||||
@@ -138,6 +139,7 @@ export type DashboardWidgetChartType =
|
||||
(typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
|
||||
export const ActionType = {
|
||||
WEBHOOK: "WEBHOOK",
|
||||
SLACK: "SLACK",
|
||||
} as const;
|
||||
export type ActionType = (typeof ActionType)[keyof typeof ActionType];
|
||||
export const ActionExecutionStatus = {
|
||||
@@ -148,6 +150,11 @@ export const ActionExecutionStatus = {
|
||||
} as const;
|
||||
export type ActionExecutionStatus =
|
||||
(typeof ActionExecutionStatus)[keyof typeof ActionExecutionStatus];
|
||||
export const SurveyName = {
|
||||
ORG_ONBOARDING: "org_onboarding",
|
||||
USER_ONBOARDING: "user_onboarding",
|
||||
} as const;
|
||||
export type SurveyName = (typeof SurveyName)[keyof typeof SurveyName];
|
||||
export type Account = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -183,6 +190,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;
|
||||
@@ -359,6 +374,8 @@ export type Dataset = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
metadata: unknown | null;
|
||||
remote_experiment_url: string | null;
|
||||
remote_experiment_payload: unknown | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
@@ -624,6 +641,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;
|
||||
@@ -711,6 +737,16 @@ export type Session = {
|
||||
user_id: string;
|
||||
expires: Timestamp;
|
||||
};
|
||||
export type SlackIntegration = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
bot_token: string;
|
||||
bot_user_id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type SsoConfig = {
|
||||
domain: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -718,6 +754,15 @@ export type SsoConfig = {
|
||||
auth_provider: string;
|
||||
auth_config: unknown | null;
|
||||
};
|
||||
export type Survey = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
survey_name: SurveyName;
|
||||
response: unknown;
|
||||
user_id: string | null;
|
||||
user_email: string | null;
|
||||
org_id: string | null;
|
||||
};
|
||||
export type TableViewPreset = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -781,6 +826,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;
|
||||
@@ -813,6 +859,7 @@ export type DB = {
|
||||
observations: LegacyPrismaObservation;
|
||||
organization_memberships: OrganizationMembership;
|
||||
organizations: Organization;
|
||||
pending_deletions: PendingDeletion;
|
||||
posthog_integrations: PosthogIntegration;
|
||||
prices: Price;
|
||||
project_memberships: ProjectMembership;
|
||||
@@ -823,7 +870,9 @@ export type DB = {
|
||||
score_configs: ScoreConfig;
|
||||
scores: LegacyPrismaScore;
|
||||
Session: Session;
|
||||
slack_integrations: SlackIntegration;
|
||||
sso_configs: SsoConfig;
|
||||
surveys: Survey;
|
||||
table_view_presets: TableViewPreset;
|
||||
trace_media: TraceMedia;
|
||||
trace_sessions: TraceSession;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "datasets" ADD COLUMN "remote_experiment_payload" JSONB,
|
||||
ADD COLUMN "remote_experiment_url" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AnnotationQueueObjectType" ADD VALUE 'SESSION';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration: Add Slack Integration Support
|
||||
-- This migration adds support for Slack automation actions by:
|
||||
-- 1. Adding SLACK to the ActionType enum
|
||||
-- 2. Creating slack_integrations table for centralized token storage
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ActionType" ADD VALUE 'SLACK';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "slack_integrations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"team_id" TEXT NOT NULL,
|
||||
"team_name" TEXT NOT NULL,
|
||||
"bot_token" TEXT NOT NULL,
|
||||
"bot_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "slack_integrations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "slack_integrations_project_id_key" ON "slack_integrations"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "slack_integrations_team_id_idx" ON "slack_integrations"("team_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "slack_integrations" ADD CONSTRAINT "slack_integrations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+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;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SurveyName" AS ENUM ('org_onboarding', 'user_onboarding');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "surveys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"survey_name" "SurveyName" NOT NULL,
|
||||
"response" JSONB NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"user_email" TEXT,
|
||||
"org_id" TEXT,
|
||||
|
||||
CONSTRAINT "surveys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "surveys" ADD CONSTRAINT "surveys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "annotation_queue_assignments_project_id_queue_id_key" RENAME TO "annotation_queue_assignments_project_id_queue_id_user_id_key";
|
||||
@@ -65,29 +65,31 @@ 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[]
|
||||
surveys Survey[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -112,57 +114,61 @@ model Organization {
|
||||
projects Project[]
|
||||
MembershipInvitation MembershipInvitation[]
|
||||
ApiKey ApiKey[]
|
||||
surveys Survey[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
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[]
|
||||
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")
|
||||
@@ -489,15 +495,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])
|
||||
@@ -539,6 +546,22 @@ enum AnnotationQueueStatus {
|
||||
enum AnnotationQueueObjectType {
|
||||
TRACE
|
||||
OBSERVATION
|
||||
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 {
|
||||
@@ -551,16 +574,18 @@ model CronJobs {
|
||||
}
|
||||
|
||||
model Dataset {
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetItems DatasetItem[]
|
||||
datasetRuns DatasetRuns[]
|
||||
id String @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
name String
|
||||
description String?
|
||||
metadata Json?
|
||||
remoteExperimentUrl String? @map("remote_experiment_url")
|
||||
remoteExperimentPayload Json? @map("remote_experiment_payload")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetItems DatasetItem[]
|
||||
datasetRuns DatasetRuns[]
|
||||
|
||||
@@id([id, projectId])
|
||||
@@unique([projectId, name])
|
||||
@@ -1295,6 +1320,7 @@ model Automation {
|
||||
|
||||
enum ActionType {
|
||||
WEBHOOK
|
||||
SLACK
|
||||
// More action types can be added as needed
|
||||
}
|
||||
|
||||
@@ -1335,3 +1361,62 @@ model AutomationExecution {
|
||||
@@index([projectId])
|
||||
@@map("automation_executions")
|
||||
}
|
||||
|
||||
// Slack Integration: Stores centralized Slack workspace connection for each project
|
||||
// One project can connect to one Slack workspace, supporting multiple channel automations
|
||||
model SlackIntegration {
|
||||
id String @id @default(cuid())
|
||||
projectId String @unique @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Installation details (encrypted using shared encryption utilities)
|
||||
teamId String @map("team_id") // Slack workspace ID
|
||||
teamName String @map("team_name") // Human-readable workspace name
|
||||
botToken String @map("bot_token") // Encrypted bot token for API calls
|
||||
botUserId String @map("bot_user_id") // Bot user ID for workspace
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_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")
|
||||
}
|
||||
|
||||
model Survey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
surveyName SurveyName @map("survey_name")
|
||||
response Json
|
||||
userId String? @map("user_id")
|
||||
userEmail String? @map("user_email")
|
||||
orgId String? @map("org_id")
|
||||
org Organization? @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("surveys")
|
||||
}
|
||||
|
||||
enum SurveyName {
|
||||
ORG_ONBOARDING @map("org_onboarding")
|
||||
USER_ONBOARDING @map("user_onboarding")
|
||||
|
||||
@@map("SurveyName")
|
||||
}
|
||||
|
||||
@@ -11,29 +11,29 @@
|
||||
## 🎯 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
\`\`\`bash
|
||||
npm install @langfuse/core
|
||||
pip install langfuse
|
||||
\`\`\`bash
|
||||
npm install @langfuse/core
|
||||
pip install langfuse
|
||||
\`\`\`
|
||||
|
||||
### Quick Setup
|
||||
1. **Initialize your project**
|
||||
\`\`\`typescript
|
||||
import { Langfuse } from 'langfuse'
|
||||
|
||||
const langfuse = new Langfuse({
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
baseUrl: 'https://cloud.langfuse.com'
|
||||
})
|
||||
\`\`\`typescript
|
||||
import { Langfuse } from 'langfuse'
|
||||
|
||||
const langfuse = new Langfuse({
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
baseUrl: 'https://cloud.langfuse.com'
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
2. **Create your first trace**
|
||||
\`\`\`python
|
||||
from langfuse import Langfuse
|
||||
|
||||
langfuse = Langfuse()
|
||||
trace = langfuse.trace(name="chat-application")
|
||||
\`\`\`python
|
||||
from langfuse import Langfuse
|
||||
|
||||
langfuse = Langfuse()
|
||||
trace = langfuse.trace(name="chat-application")
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -72,75 +72,75 @@ graph TD
|
||||
> **Note:** Traces are the foundation of observability in LLM applications.
|
||||
|
||||
#### Creating Traces
|
||||
\`\`\`typescript
|
||||
// Basic trace creation
|
||||
const trace = langfuse.trace({
|
||||
name: "user-query-processing",
|
||||
userId: "user-123",
|
||||
sessionId: "session-456",
|
||||
metadata: {
|
||||
environment: "production",
|
||||
version: "2.1.0"
|
||||
}
|
||||
})
|
||||
\`\`\`typescript
|
||||
// Basic trace creation
|
||||
const trace = langfuse.trace({
|
||||
name: "user-query-processing",
|
||||
userId: "user-123",
|
||||
sessionId: "session-456",
|
||||
metadata: {
|
||||
environment: "production",
|
||||
version: "2.1.0"
|
||||
}
|
||||
})
|
||||
|
||||
// Nested observations
|
||||
const span = trace.span({
|
||||
name: "document-retrieval",
|
||||
input: { query: "What is machine learning?" },
|
||||
metadata: { vectorStore: "pinecone" }
|
||||
})
|
||||
// Nested observations
|
||||
const span = trace.span({
|
||||
name: "document-retrieval",
|
||||
input: { query: "What is machine learning?" },
|
||||
metadata: { vectorStore: "pinecone" }
|
||||
})
|
||||
|
||||
const generation = span.generation({
|
||||
name: "answer-generation",
|
||||
model: "gpt-4",
|
||||
input: retrievedDocs,
|
||||
output: generatedAnswer,
|
||||
usage: {
|
||||
promptTokens: 1250,
|
||||
completionTokens: 420,
|
||||
totalTokens: 1670
|
||||
}
|
||||
})
|
||||
const generation = span.generation({
|
||||
name: "answer-generation",
|
||||
model: "gpt-4",
|
||||
input: retrievedDocs,
|
||||
output: generatedAnswer,
|
||||
usage: {
|
||||
promptTokens: 1250,
|
||||
completionTokens: 420,
|
||||
totalTokens: 1670
|
||||
}
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
### Advanced Features
|
||||
|
||||
#### 🔄 Async Processing
|
||||
\`\`\`python
|
||||
import asyncio
|
||||
from langfuse import Langfuse
|
||||
\`\`\`python
|
||||
import asyncio
|
||||
from langfuse import Langfuse
|
||||
|
||||
async def process_batch():
|
||||
langfuse = Langfuse()
|
||||
|
||||
tasks = []
|
||||
for item in batch_items:
|
||||
task = asyncio.create_task(
|
||||
process_item_with_tracing(langfuse, item)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
async def process_batch():
|
||||
langfuse = Langfuse()
|
||||
|
||||
tasks = []
|
||||
for item in batch_items:
|
||||
task = asyncio.create_task(
|
||||
process_item_with_tracing(langfuse, item)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
\`\`\`
|
||||
|
||||
#### 🎯 Custom Scoring
|
||||
\`\`\`typescript
|
||||
// Automated scoring
|
||||
trace.score({
|
||||
name: "relevance",
|
||||
value: 0.95,
|
||||
comment: "Highly relevant response"
|
||||
})
|
||||
\`\`\`typescript
|
||||
// Automated scoring
|
||||
trace.score({
|
||||
name: "relevance",
|
||||
value: 0.95,
|
||||
comment: "Highly relevant response"
|
||||
})
|
||||
|
||||
// Human feedback scoring
|
||||
trace.score({
|
||||
name: "user-satisfaction",
|
||||
value: 1,
|
||||
source: "user-feedback",
|
||||
comment: "User rated 5/5 stars"
|
||||
})
|
||||
// Human feedback scoring
|
||||
trace.score({
|
||||
name: "user-satisfaction",
|
||||
value: 1,
|
||||
source: "user-feedback",
|
||||
comment: "User rated 5/5 stars"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -156,20 +156,20 @@ trace.score({
|
||||
- **User Satisfaction**: Quality metrics
|
||||
|
||||
#### Dashboard Setup
|
||||
\`\`\`yaml
|
||||
# monitoring-config.yml
|
||||
dashboards:
|
||||
- name: "LLM Performance"
|
||||
panels:
|
||||
- type: "time-series"
|
||||
title: "Response Latency"
|
||||
query: "avg(response_time) by (model)"
|
||||
- type: "stat"
|
||||
title: "Daily Token Usage"
|
||||
query: "sum(tokens_used)"
|
||||
- type: "table"
|
||||
title: "Top Errors"
|
||||
query: "topk(10, count by (error_type))"
|
||||
\`\`\`yaml
|
||||
# monitoring-config.yml
|
||||
dashboards:
|
||||
- name: "LLM Performance"
|
||||
panels:
|
||||
- type: "time-series"
|
||||
title: "Response Latency"
|
||||
query: "avg(response_time) by (model)"
|
||||
- type: "stat"
|
||||
title: "Daily Token Usage"
|
||||
query: "sum(tokens_used)"
|
||||
- type: "table"
|
||||
title: "Top Errors"
|
||||
query: "topk(10, count by (error_type))"
|
||||
\`\`\`
|
||||
|
||||
### 🔐 Security Considerations
|
||||
@@ -177,40 +177,40 @@ dashboards:
|
||||
> ⚠️ **Important**: Never log sensitive user data in traces
|
||||
|
||||
#### Data Sanitization
|
||||
\`\`\`python
|
||||
def sanitize_input(data):
|
||||
"""Remove PII from trace data"""
|
||||
sanitized = data.copy()
|
||||
|
||||
# Remove email addresses
|
||||
sanitized = re.sub(r'\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b',
|
||||
'[EMAIL_REDACTED]', sanitized)
|
||||
|
||||
# Remove phone numbers
|
||||
sanitized = re.sub(r'\\b\\d{3}-\\d{3}-\\d{4}\\b',
|
||||
'[PHONE_REDACTED]', sanitized)
|
||||
|
||||
return sanitized
|
||||
\`\`\`python
|
||||
def sanitize_input(data):
|
||||
"""Remove PII from trace data"""
|
||||
sanitized = data.copy()
|
||||
|
||||
# Remove email addresses
|
||||
sanitized = re.sub(r'\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b',
|
||||
'[EMAIL_REDACTED]', sanitized)
|
||||
|
||||
# Remove phone numbers
|
||||
sanitized = re.sub(r'\\b\\d{3}-\\d{3}-\\d{4}\\b',
|
||||
'[PHONE_REDACTED]', sanitized)
|
||||
|
||||
return sanitized
|
||||
\`\`\`
|
||||
|
||||
### 🚀 Performance Optimization
|
||||
|
||||
#### Batch Processing
|
||||
\`\`\`typescript
|
||||
// Efficient batch uploads
|
||||
const batchSize = 100
|
||||
const traces = []
|
||||
\`\`\`typescript
|
||||
// Efficient batch uploads
|
||||
const batchSize = 100
|
||||
const traces = []
|
||||
|
||||
for (let i = 0; i < data.length; i += batchSize) {
|
||||
const batch = data.slice(i, i + batchSize)
|
||||
const processedBatch = await Promise.all(
|
||||
batch.map(item => processWithLangfuse(item))
|
||||
)
|
||||
traces.push(...processedBatch)
|
||||
}
|
||||
for (let i = 0; i < data.length; i += batchSize) {
|
||||
const batch = data.slice(i, i + batchSize)
|
||||
const processedBatch = await Promise.all(
|
||||
batch.map(item => processWithLangfuse(item))
|
||||
)
|
||||
traces.push(...processedBatch)
|
||||
}
|
||||
|
||||
// Flush all traces at once
|
||||
await langfuse.flushAsync()
|
||||
// Flush all traces at once
|
||||
await langfuse.flushAsync()
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -219,34 +219,34 @@ await langfuse.flushAsync()
|
||||
|
||||
### Multi-Agent System Tracing
|
||||
\`\`\`python
|
||||
class MultiAgentTracer:
|
||||
def __init__(self):
|
||||
self.langfuse = Langfuse()
|
||||
|
||||
async def orchestrate_agents(self, task):
|
||||
# Main orchestration trace
|
||||
main_trace = self.langfuse.trace(
|
||||
name="multi-agent-orchestration",
|
||||
input={"task": task}
|
||||
)
|
||||
|
||||
# Agent 1: Research
|
||||
research_span = main_trace.span(name="research-agent")
|
||||
research_result = await self.research_agent.process(task)
|
||||
research_span.end(output=research_result)
|
||||
|
||||
class MultiAgentTracer:
|
||||
def __init__(self):
|
||||
self.langfuse = Langfuse()
|
||||
|
||||
async def orchestrate_agents(self, task):
|
||||
# Main orchestration trace
|
||||
main_trace = self.langfuse.trace(
|
||||
name="multi-agent-orchestration",
|
||||
input={"task": task}
|
||||
)
|
||||
|
||||
# Agent 1: Research
|
||||
research_span = main_trace.span(name="research-agent")
|
||||
research_result = await self.research_agent.process(task)
|
||||
research_span.end(output=research_result)
|
||||
|
||||
# Agent 2: Analysis
|
||||
analysis_span = main_trace.span(name="analysis-agent")
|
||||
analysis_result = await self.analysis_agent.process(research_result)
|
||||
analysis_span.end(output=analysis_result)
|
||||
|
||||
# Agent 3: Synthesis
|
||||
synthesis_span = main_trace.span(name="synthesis-agent")
|
||||
final_result = await self.synthesis_agent.process(analysis_result)
|
||||
synthesis_span.end(output=final_result)
|
||||
|
||||
main_trace.end(output=final_result)
|
||||
return final_result
|
||||
analysis_span = main_trace.span(name="analysis-agent")
|
||||
analysis_result = await self.analysis_agent.process(research_result)
|
||||
analysis_span.end(output=analysis_result)
|
||||
|
||||
# Agent 3: Synthesis
|
||||
synthesis_span = main_trace.span(name="synthesis-agent")
|
||||
final_result = await self.synthesis_agent.process(analysis_result)
|
||||
synthesis_span.end(output=final_result)
|
||||
|
||||
main_trace.end(output=final_result)
|
||||
return final_result
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -256,11 +256,11 @@ class MultiAgentTracer:
|
||||
With proper implementation of Langfuse tracing, you can:
|
||||
|
||||
- ✅ **Monitor** your LLM applications in real-time
|
||||
- ✅ **Debug** issues with detailed trace information
|
||||
- ✅ **Debug** issues with detailed trace information
|
||||
- ✅ **Optimize** performance and costs
|
||||
- ✅ **Scale** your applications with confidence
|
||||
|
||||
### Next Steps
|
||||
1. Review the [official documentation](https://langfuse.com/docs)
|
||||
2. Join our [Discord community](https://discord.gg/langfuse)
|
||||
3. Check out [example projects](https://github.com/langfuse/langfuse)
|
||||
3. Check out [example projects](https://github.com/langfuse/langfuse)
|
||||
|
||||
@@ -30,14 +30,14 @@ export type AutomationDomain = {
|
||||
};
|
||||
|
||||
export type ActionDomain = Omit<Action, "config"> & {
|
||||
config: SafeWebhookActionConfig;
|
||||
config: SafeActionConfig;
|
||||
};
|
||||
|
||||
export type ActionDomainWithSecrets = Omit<Action, "config"> & {
|
||||
config: WebhookActionConfigWithSecrets;
|
||||
config: ActionConfigWithSecrets;
|
||||
};
|
||||
|
||||
export const ActionTypeSchema = z.enum(["WEBHOOK"]);
|
||||
export const ActionTypeSchema = z.enum(["WEBHOOK", "SLACK"]);
|
||||
|
||||
export const AvailableWebhookApiSchema = z.record(
|
||||
z.enum(["prompt"]),
|
||||
@@ -52,12 +52,13 @@ export const RequestHeaderSchema = z.object({
|
||||
export const WebhookActionConfigSchema = z.object({
|
||||
type: z.literal("WEBHOOK"),
|
||||
url: z.url(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
requestHeaders: z.record(z.string(), RequestHeaderSchema),
|
||||
displayHeaders: z.record(z.string(), RequestHeaderSchema),
|
||||
headers: z.record(z.string(), z.string()).optional(), // deprecated field, use requestHeaders instead
|
||||
requestHeaders: z.record(z.string(), RequestHeaderSchema).optional(), // might not exist on legacy webhooks
|
||||
displayHeaders: z.record(z.string(), RequestHeaderSchema).optional(), // might not exist on legacy webhooks
|
||||
apiVersion: AvailableWebhookApiSchema,
|
||||
secretKey: z.string(),
|
||||
displaySecretKey: z.string(),
|
||||
lastFailingExecutionId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const SafeWebhookActionConfigSchema = WebhookActionConfigSchema.omit({
|
||||
@@ -77,18 +78,94 @@ export const WebhookActionCreateSchema = WebhookActionConfigSchema.omit({
|
||||
displayHeaders: true,
|
||||
});
|
||||
|
||||
export const SlackActionConfigSchema = z.object({
|
||||
type: z.literal("SLACK"),
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
messageTemplate: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SlackActionConfig = z.infer<typeof SlackActionConfigSchema>;
|
||||
|
||||
export const ActionConfigSchema = z.discriminatedUnion("type", [
|
||||
WebhookActionConfigSchema,
|
||||
SlackActionConfigSchema,
|
||||
]);
|
||||
|
||||
export const ActionCreateSchema = z.discriminatedUnion("type", [
|
||||
WebhookActionCreateSchema,
|
||||
SlackActionConfigSchema,
|
||||
]);
|
||||
|
||||
export const SafeActionConfigSchema = z.discriminatedUnion("type", [
|
||||
SafeWebhookActionConfigSchema,
|
||||
SlackActionConfigSchema,
|
||||
]);
|
||||
|
||||
export type ActionTypes = z.infer<typeof ActionTypeSchema>;
|
||||
export type ActionConfig = z.infer<typeof ActionConfigSchema>;
|
||||
export type ActionCreate = z.infer<typeof ActionCreateSchema>;
|
||||
export type SafeActionConfig = z.infer<typeof SafeActionConfigSchema>;
|
||||
|
||||
export type WebhookActionCreate = z.infer<typeof WebhookActionCreateSchema>;
|
||||
export type WebhookActionConfigWithSecrets = z.infer<
|
||||
typeof WebhookActionConfigSchema
|
||||
>;
|
||||
|
||||
export type ActionConfigWithSecrets = z.infer<typeof ActionConfigSchema>;
|
||||
|
||||
// Type Guards for Runtime Validation
|
||||
// Using existing Zod schemas to provide both compile-time and runtime type safety
|
||||
|
||||
/**
|
||||
* Type guard to check if a config is a valid webhook configuration with secrets
|
||||
*/
|
||||
export function isWebhookActionConfig(
|
||||
config: unknown,
|
||||
): config is WebhookActionConfigWithSecrets {
|
||||
return WebhookActionConfigSchema.safeParse(config).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a config is a valid Slack configuration
|
||||
*/
|
||||
export function isSlackActionConfig(
|
||||
config: unknown,
|
||||
): config is SlackActionConfig {
|
||||
return SlackActionConfigSchema.safeParse(config).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an entire action has valid webhook configuration
|
||||
*/
|
||||
export function isWebhookAction(action: {
|
||||
type: string;
|
||||
config: unknown;
|
||||
}): action is { type: "WEBHOOK"; config: WebhookActionConfigWithSecrets } {
|
||||
return action.type === "WEBHOOK" && isWebhookActionConfig(action.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for safe webhook config (without secrets)
|
||||
*/
|
||||
export function isSafeWebhookActionConfig(
|
||||
config: unknown,
|
||||
): config is SafeWebhookActionConfig {
|
||||
return SafeWebhookActionConfigSchema.safeParse(config).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts webhook config with secrets to safe config by only including allowed fields
|
||||
*/
|
||||
export function convertToSafeWebhookConfig(
|
||||
webhookConfig: WebhookActionConfigWithSecrets,
|
||||
): SafeWebhookActionConfig {
|
||||
return {
|
||||
type: webhookConfig.type,
|
||||
url: webhookConfig.url,
|
||||
displayHeaders: webhookConfig.displayHeaders,
|
||||
apiVersion: webhookConfig.apiVersion,
|
||||
displaySecretKey: webhookConfig.displaySecretKey,
|
||||
lastFailingExecutionId: webhookConfig.lastFailingExecutionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,60 @@ 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()
|
||||
.transform((val) => {
|
||||
try {
|
||||
if (!val) return new Map<string, number>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
const parts = val.split(",");
|
||||
|
||||
for (const part of parts) {
|
||||
const [projectId, sampleRateStr] = part.split(":");
|
||||
|
||||
if (!projectId || sampleRateStr === undefined) {
|
||||
throw new Error(`Invalid format: ${part}`);
|
||||
}
|
||||
|
||||
// Validate sample rate is between 0 and 1
|
||||
const sampleRate = z.coerce
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.parse(sampleRateStr);
|
||||
|
||||
map.set(projectId, sampleRate);
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (err) {
|
||||
return new Map<string, number>();
|
||||
}
|
||||
}),
|
||||
|
||||
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
|
||||
>;
|
||||
|
||||
@@ -14,6 +14,8 @@ const ActionIdSchema = z.enum([
|
||||
"score-delete",
|
||||
"trace-delete",
|
||||
"trace-add-to-annotation-queue",
|
||||
"session-add-to-annotation-queue",
|
||||
"observation-add-to-annotation-queue",
|
||||
]);
|
||||
|
||||
export type ActionId = z.infer<typeof ActionIdSchema>;
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from "./features/entitlements/plans";
|
||||
export * from "./interfaces/rate-limits";
|
||||
export * from "./tableDefinitions/typeHelpers";
|
||||
export * from "./domain/webhooks";
|
||||
export * from "./domain/dataset-run-items";
|
||||
|
||||
// llm api
|
||||
export * from "./server/llm/types";
|
||||
|
||||
@@ -18,6 +18,21 @@ export const CloudConfigSchema = z.object({
|
||||
|
||||
// custom rate limits for an organization
|
||||
rateLimitOverrides: CloudConfigRateLimit.optional(),
|
||||
|
||||
// billing alert configuration
|
||||
usageAlerts: z
|
||||
.object({
|
||||
enabled: z.boolean().default(true),
|
||||
type: z.enum(["STRIPE"]).default("STRIPE"),
|
||||
threshold: z.number().int().positive(),
|
||||
alertId: z.string(), // Alert ID for tracking
|
||||
meterId: z.string(), // Meter ID for usage tracking
|
||||
notifications: z.object({
|
||||
email: z.boolean().default(true),
|
||||
recipients: z.array(z.string().email()).default([]),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CloudConfigSchema = z.infer<typeof CloudConfigSchema>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod/v4";
|
||||
import { Plan, plans } from "../../features/entitlements/plans";
|
||||
import { CloudConfigRateLimit } from "../../interfaces/rate-limits";
|
||||
import { ApiKeyScope } from "../../";
|
||||
import { ApiKeyScope, MakeOptional } from "../../";
|
||||
|
||||
const ApiKeyBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -48,12 +48,25 @@ export type AuthHeaderValidVerificationResult = {
|
||||
scope: ApiAccessScope;
|
||||
};
|
||||
|
||||
export type ApiAccessScope = {
|
||||
export type AuthHeaderValidVerificationResultIngestion = {
|
||||
validKey: true;
|
||||
scope: ApiAccessScopeIngestion;
|
||||
};
|
||||
|
||||
type BaseApiAccessScope = {
|
||||
projectId: string | null;
|
||||
accessLevel: "organization" | "project" | "scores";
|
||||
};
|
||||
|
||||
type ApiAccessScopeMetadata = {
|
||||
orgId: string;
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
apiKeyId: string;
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
export type ApiAccessScopeIngestion = BaseApiAccessScope &
|
||||
MakeOptional<ApiAccessScopeMetadata>;
|
||||
|
||||
export type ApiAccessScope = BaseApiAccessScope & ApiAccessScopeMetadata;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ export const ClickhouseTableNames = {
|
||||
traces: "traces",
|
||||
observations: "observations",
|
||||
scores: "scores",
|
||||
dataset_run_items: "dataset_run_items",
|
||||
|
||||
// Virtual tables for dashboards
|
||||
// TODO: Check if we can do this more elegantly
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DatasetDeleteQueue } from "../redis/datasetDelete";
|
||||
import { QueueJobs } from "../queues";
|
||||
import { redis } from "../redis/redis";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
type DatasetDeletionType = "dataset" | "dataset-runs";
|
||||
|
||||
type DatasetDeletionPayload = {
|
||||
deletionType: DatasetDeletionType;
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
datasetRunIds?: string[];
|
||||
};
|
||||
|
||||
export const addToDeleteDatasetQueue = async ({
|
||||
deletionType,
|
||||
projectId,
|
||||
datasetId,
|
||||
datasetRunIds = [],
|
||||
}: DatasetDeletionPayload) => {
|
||||
if (redis) {
|
||||
await DatasetDeleteQueue.getInstance()?.add(QueueJobs.DatasetDelete, {
|
||||
payload: {
|
||||
deletionType,
|
||||
projectId,
|
||||
datasetId,
|
||||
datasetRunIds,
|
||||
},
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
name: QueueJobs.DatasetDelete,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from "./services/StorageService";
|
||||
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
|
||||
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/email/billingAlert/sendBillingAlertEmail";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./services/PromptService/types";
|
||||
export * from "./services/traces-ui-table-service";
|
||||
@@ -32,6 +33,7 @@ export * from "./redis/webhookQueue";
|
||||
export * from "./redis/traceDelete";
|
||||
export * from "./redis/projectDelete";
|
||||
export * from "./redis/scoreDelete";
|
||||
export * from "./redis/datasetDelete";
|
||||
export * from "./redis/datasetRunItemUpsert";
|
||||
export * from "./redis/batchExport";
|
||||
export * from "./redis/batchActionQueue";
|
||||
@@ -56,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";
|
||||
@@ -63,13 +66,17 @@ export * from "./services/DashboardService";
|
||||
export * from "./services/TableViewService";
|
||||
export * from "./services/DefaultEvaluationModelService";
|
||||
export * from "./clickhouse/measureAndReturn";
|
||||
export * from "./services/SlackService";
|
||||
|
||||
export * from "./data-deletion/ingestionFileDeletion";
|
||||
export * from "./s3";
|
||||
|
||||
// dataset run items
|
||||
export * from "./dataset-run-items/datasetExecution";
|
||||
export * from "./dataset-run-items/types";
|
||||
export * from "./dataset-run-items/addToDeleteQueue";
|
||||
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
export * from "./utils/headerUtils";
|
||||
export * from "./traceDeletionProcessor";
|
||||
|
||||
@@ -83,7 +83,7 @@ const getModelFromRedis = async (
|
||||
if (redisModel) {
|
||||
recordIncrement("langfuse.model_match.cache_hit", 1);
|
||||
if (redisModel === NOT_FOUND_TOKEN) {
|
||||
return null;
|
||||
return NOT_FOUND_TOKEN;
|
||||
}
|
||||
const model = redisModelToPrismaModel(redisModel);
|
||||
return model;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LangfuseNotFoundError,
|
||||
UnauthorizedError,
|
||||
} from "../../errors";
|
||||
import { AuthHeaderValidVerificationResult } from "../auth/types";
|
||||
import { AuthHeaderValidVerificationResultIngestion } from "../auth/types";
|
||||
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
|
||||
import {
|
||||
getCurrentSpan,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
StorageService,
|
||||
StorageServiceFactory,
|
||||
} from "../services/StorageService";
|
||||
import { isTraceIdInSample } from "./sampling";
|
||||
|
||||
let s3StorageServiceClient: StorageService;
|
||||
|
||||
@@ -61,7 +62,7 @@ export type TokenCountDelegate = (p: {
|
||||
* We need the delay around date boundaries to avoid duplicates for out-of-order processing of events.
|
||||
* @param delay - Delay overwrite. Used if non-null.
|
||||
*/
|
||||
const getDelay = (delay: number | null) => {
|
||||
const getDelay = (delay: number | null, source: "api" | "otel") => {
|
||||
if (delay !== null) {
|
||||
return delay;
|
||||
}
|
||||
@@ -73,6 +74,10 @@ const getDelay = (delay: number | null) => {
|
||||
return env.LANGFUSE_INGESTION_QUEUE_DELAY_MS;
|
||||
}
|
||||
|
||||
if (source === "otel") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use 5s here to avoid duplicate processing on the worker. If the ingestion delay is set to a lower value,
|
||||
// we use this instead.
|
||||
// Values should be revisited based on a cost/performance trade-off.
|
||||
@@ -94,12 +99,12 @@ type ProcessEventBatchOptions = {
|
||||
/**
|
||||
* Processes a batch of events.
|
||||
* @param input - Batch of IngestionEventType. Will validate the types first thing and return errors if they are invalid.
|
||||
* @param authCheck - AuthHeaderValidVerificationResult
|
||||
* @param authCheck - AuthHeaderValidVerificationResultIngestion
|
||||
* @param options - (Optional) Options for the event batch processing.
|
||||
*/
|
||||
export const processEventBatch = async (
|
||||
input: unknown[],
|
||||
authCheck: AuthHeaderValidVerificationResult,
|
||||
authCheck: AuthHeaderValidVerificationResultIngestion,
|
||||
options: ProcessEventBatchOptions = {},
|
||||
): Promise<{
|
||||
successes: { id: string; status: number }[];
|
||||
@@ -124,8 +129,10 @@ export const processEventBatch = async (
|
||||
"langfuse.project.id",
|
||||
authCheck.scope.projectId ?? "",
|
||||
);
|
||||
currentSpan?.setAttribute("langfuse.org.id", authCheck.scope.orgId);
|
||||
currentSpan?.setAttribute("langfuse.org.plan", authCheck.scope.plan);
|
||||
if (authCheck.scope.orgId)
|
||||
currentSpan?.setAttribute("langfuse.org.id", authCheck.scope.orgId);
|
||||
if (authCheck.scope.plan)
|
||||
currentSpan?.setAttribute("langfuse.org.plan", authCheck.scope.plan);
|
||||
|
||||
/**************
|
||||
* VALIDATION *
|
||||
@@ -269,6 +276,27 @@ export const processEventBatch = async (
|
||||
const shouldSkipS3List =
|
||||
isDatasetRunItemEvent || (isObservationEvent && isOtelOrSkipS3Project);
|
||||
|
||||
const { isSampled, isSamplingConfigured } = isTraceIdInSample({
|
||||
projectId: authCheck.scope.projectId,
|
||||
event: eventData.data[0],
|
||||
});
|
||||
|
||||
if (!isSampled) {
|
||||
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
|
||||
projectId: authCheck.scope.projectId ?? "<not set>",
|
||||
sampling_decision: "out",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSamplingConfigured) {
|
||||
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
|
||||
projectId: authCheck.scope.projectId ?? "<not set>",
|
||||
sampling_decision: "in",
|
||||
});
|
||||
}
|
||||
|
||||
return queue
|
||||
? queue.add(
|
||||
QueueJobs.IngestionJob,
|
||||
@@ -292,7 +320,7 @@ export const processEventBatch = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
{ delay: getDelay(delay) },
|
||||
{ delay: getDelay(delay, source) },
|
||||
)
|
||||
: Promise.reject("Failed to instantiate queue");
|
||||
}),
|
||||
@@ -307,7 +335,7 @@ export const processEventBatch = async (
|
||||
|
||||
const isAuthorized = (
|
||||
event: IngestionEventType,
|
||||
authScope: AuthHeaderValidVerificationResult,
|
||||
authScope: AuthHeaderValidVerificationResultIngestion,
|
||||
): boolean => {
|
||||
if (event.type === eventTypes.SDK_LOG) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import crypto from "node:crypto";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
import { IngestionEventType } from "./types";
|
||||
|
||||
export function isTraceIdInSample(params: {
|
||||
projectId: string | null;
|
||||
event: IngestionEventType;
|
||||
}): { isSampled: boolean; isSamplingConfigured: boolean } {
|
||||
const { projectId, event } = params;
|
||||
|
||||
const sampledProjects = env.LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS;
|
||||
|
||||
if (!projectId || !sampledProjects.has(projectId))
|
||||
return { isSampled: true, isSamplingConfigured: false };
|
||||
|
||||
const sampleRate = sampledProjects.get(projectId);
|
||||
if (sampleRate === undefined)
|
||||
return { isSampled: true, isSamplingConfigured: true };
|
||||
|
||||
const traceId = parseTraceId(event);
|
||||
if (!traceId) return { isSampled: true, isSamplingConfigured: true };
|
||||
|
||||
return {
|
||||
isSampled: isInSample(traceId, sampleRate),
|
||||
isSamplingConfigured: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isInSample(traceId: string, sampleRate: number) {
|
||||
if (sampleRate < 0 || sampleRate > 1) {
|
||||
logger.error(`Invalid sample rate ${sampleRate}`);
|
||||
|
||||
// Be conservative and keep the trace ID in sample for invalid configs
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sampleRate === 0) return false;
|
||||
if (sampleRate === 1) return true;
|
||||
|
||||
// Create SHA-256 hash of the input
|
||||
const hash = crypto.createHash("sha256").update(traceId).digest("hex");
|
||||
|
||||
// Take first 8 characters and convert to integer
|
||||
// Equivalent to 4 bytes, 32 bit integer
|
||||
const hashInt = parseInt(hash.substring(0, 8), 16);
|
||||
|
||||
// Convert to a value between 0 and 1 by dividing by largest integer
|
||||
const normalizedHash = hashInt / 0xffffffff;
|
||||
|
||||
// Return true if normalized hash is less than sample rate
|
||||
return normalizedHash < sampleRate;
|
||||
}
|
||||
|
||||
function parseTraceId(event: IngestionEventType): string | null | undefined {
|
||||
if (event.type === "trace-create") return event.body.id;
|
||||
|
||||
return "traceId" in event.body ? event.body.traceId : null;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -113,6 +113,7 @@ export enum ChatMessageRole {
|
||||
User = "user",
|
||||
Assistant = "assistant",
|
||||
Tool = "tool",
|
||||
Model = "model", // Google Gemini assistant format
|
||||
}
|
||||
|
||||
// Thought: should placeholder not semantically be part of this, because it can be
|
||||
@@ -124,6 +125,7 @@ export enum ChatMessageType {
|
||||
AssistantText = "assistant-text",
|
||||
AssistantToolCall = "assistant-tool-call",
|
||||
ToolResult = "tool-result",
|
||||
ModelText = "model-text",
|
||||
PublicAPICreated = "public-api-created",
|
||||
Placeholder = "placeholder",
|
||||
}
|
||||
@@ -156,6 +158,13 @@ export const AssistantTextMessageSchema = z.object({
|
||||
});
|
||||
export type AssistantTextMessage = z.infer<typeof AssistantTextMessageSchema>;
|
||||
|
||||
export const ModelMessageSchema = z.object({
|
||||
type: z.literal(ChatMessageType.ModelText),
|
||||
role: z.literal(ChatMessageRole.Model),
|
||||
content: z.string(),
|
||||
});
|
||||
export type ModelMessage = z.infer<typeof ModelMessageSchema>;
|
||||
|
||||
export const AssistantToolCallMessageSchema = z.object({
|
||||
type: z.literal(ChatMessageType.AssistantToolCall),
|
||||
role: z.literal(ChatMessageRole.Assistant),
|
||||
@@ -193,6 +202,7 @@ export const ChatMessageSchema = z.union([
|
||||
AssistantTextMessageSchema,
|
||||
AssistantToolCallMessageSchema,
|
||||
ToolResultMessageSchema,
|
||||
ModelMessageSchema,
|
||||
z
|
||||
.object({
|
||||
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
|
||||
@@ -226,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",
|
||||
@@ -322,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",
|
||||
@@ -368,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 = {
|
||||
@@ -379,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,
|
||||
|
||||
@@ -40,6 +40,21 @@ export const ScoresQueueEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
scoreIds: z.array(z.string()),
|
||||
});
|
||||
export const DatasetQueueEventSchema = z.discriminatedUnion("deletionType", [
|
||||
// Delete all run items for a specific dataset
|
||||
z.object({
|
||||
deletionType: z.literal("dataset"),
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
}),
|
||||
// Delete all run items for multiple dataset runs (also used for single run deletion)
|
||||
z.object({
|
||||
deletionType: z.literal("dataset-runs"),
|
||||
projectId: z.string(),
|
||||
datasetId: z.string(),
|
||||
datasetRunIds: z.array(z.string()),
|
||||
}),
|
||||
]);
|
||||
export const ProjectQueueEventSchema = z.object({
|
||||
projectId: z.string(),
|
||||
orgId: z.string(),
|
||||
@@ -101,6 +116,24 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
|
||||
targetId: z.string().optional(),
|
||||
type: z.enum(BatchActionType),
|
||||
}),
|
||||
z.object({
|
||||
actionId: z.literal("session-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("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"]),
|
||||
@@ -144,6 +177,7 @@ export const WebhookInputSchema = z.object({
|
||||
payload: WebhookOutboundEnvelopeSchema,
|
||||
});
|
||||
|
||||
export type WebhookInput = z.infer<typeof WebhookInputSchema>;
|
||||
export const EntityChangeEventSchema = z.discriminatedUnion("entityType", [
|
||||
z.object({
|
||||
entityType: z.literal("prompt-version"),
|
||||
@@ -154,8 +188,6 @@ export const EntityChangeEventSchema = z.discriminatedUnion("entityType", [
|
||||
}),
|
||||
// Add other entity types here in the future
|
||||
]);
|
||||
|
||||
export type WebhookInput = z.infer<typeof WebhookInputSchema>;
|
||||
export type EntityChangeEventType = z.infer<typeof EntityChangeEventSchema>;
|
||||
|
||||
export type CreateEvalQueueEventType = z.infer<
|
||||
@@ -165,6 +197,7 @@ export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
|
||||
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
|
||||
export type TracesQueueEventType = z.infer<typeof TracesQueueEventSchema>;
|
||||
export type ScoresQueueEventType = z.infer<typeof ScoresQueueEventSchema>;
|
||||
export type DatasetQueueEventType = z.infer<typeof DatasetQueueEventSchema>;
|
||||
export type ProjectQueueEventType = z.infer<typeof ProjectQueueEventSchema>;
|
||||
export type DatasetRunItemUpsertEventType = z.infer<
|
||||
typeof DatasetRunItemUpsertEventSchema
|
||||
@@ -192,6 +225,13 @@ export type DeadLetterRetryQueueEventType = z.infer<
|
||||
|
||||
export type WebhookQueueEventType = z.infer<typeof WebhookInputSchema>;
|
||||
|
||||
export const RetryBaggage = z.object({
|
||||
originalJobTimestamp: z.date(),
|
||||
attempt: z.number(),
|
||||
});
|
||||
|
||||
export type RetryBaggage = z.infer<typeof RetryBaggage>;
|
||||
|
||||
export enum QueueName {
|
||||
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
|
||||
TraceDelete = "trace-delete",
|
||||
@@ -214,6 +254,7 @@ export enum QueueName {
|
||||
BatchActionQueue = "batch-action-queue",
|
||||
CreateEvalQueue = "create-eval-queue",
|
||||
ScoreDelete = "score-delete",
|
||||
DatasetDelete = "dataset-delete-queue",
|
||||
DeadLetterRetryQueue = "dead-letter-retry-queue",
|
||||
WebhookQueue = "webhook-queue",
|
||||
EntityChangeQueue = "entity-change-queue",
|
||||
@@ -241,6 +282,7 @@ export enum QueueJobs {
|
||||
BatchActionProcessingJob = "batch-action-processing-job",
|
||||
CreateEvalJob = "create-eval-job",
|
||||
ScoreDelete = "score-delete",
|
||||
DatasetDelete = "dataset-delete-job",
|
||||
DeadLetterRetryJob = "dead-letter-retry-job",
|
||||
WebhookJob = "webhook-job",
|
||||
EntityChangeJob = "entity-change-job",
|
||||
@@ -265,6 +307,12 @@ export type TQueueJobTypes = {
|
||||
payload: ScoresQueueEventType;
|
||||
name: QueueJobs.ScoreDelete;
|
||||
};
|
||||
[QueueName.DatasetDelete]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
payload: DatasetQueueEventType;
|
||||
name: QueueJobs.DatasetDelete;
|
||||
};
|
||||
[QueueName.ProjectDelete]: {
|
||||
timestamp: Date;
|
||||
id: string;
|
||||
@@ -282,6 +330,7 @@ export type TQueueJobTypes = {
|
||||
id: string;
|
||||
payload: EvalExecutionEventType;
|
||||
name: QueueJobs.EvaluationExecution;
|
||||
retryBaggage?: RetryBaggage;
|
||||
};
|
||||
[QueueName.BatchExport]: {
|
||||
timestamp: Date;
|
||||
@@ -306,6 +355,7 @@ export type TQueueJobTypes = {
|
||||
id: string;
|
||||
payload: ExperimentCreateEventType;
|
||||
name: QueueJobs.ExperimentCreateJob;
|
||||
retryBaggage?: RetryBaggage;
|
||||
};
|
||||
[QueueName.PostHogIntegrationProcessingQueue]: {
|
||||
timestamp: Date;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import {
|
||||
createNewRedisInstance,
|
||||
redisQueueRetryOptions,
|
||||
getQueuePrefix,
|
||||
} from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class DatasetDeleteQueue {
|
||||
private static instance: Queue<
|
||||
TQueueJobTypes[QueueName.DatasetDelete]
|
||||
> | null = null;
|
||||
|
||||
public static getInstance(): Queue<
|
||||
TQueueJobTypes[QueueName.DatasetDelete]
|
||||
> | null {
|
||||
if (DatasetDeleteQueue.instance) return DatasetDeleteQueue.instance;
|
||||
|
||||
const newRedis = createNewRedisInstance({
|
||||
enableOfflineQueue: false,
|
||||
...redisQueueRetryOptions,
|
||||
});
|
||||
|
||||
DatasetDeleteQueue.instance = newRedis
|
||||
? new Queue<TQueueJobTypes[QueueName.DatasetDelete]>(
|
||||
QueueName.DatasetDelete,
|
||||
{
|
||||
connection: newRedis,
|
||||
prefix: getQueuePrefix(QueueName.DatasetDelete),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 30_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
DatasetDeleteQueue.instance?.on("error", (err) => {
|
||||
logger.error("DatasetDeleteQueue error", err);
|
||||
});
|
||||
|
||||
return DatasetDeleteQueue.instance;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export class ExperimentCreateQueue {
|
||||
attempts: 10,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 1000,
|
||||
delay: 10_000, // 10 seconds
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ScoreDeleteQueue } from "./scoreDelete";
|
||||
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
|
||||
import { WebhookQueue } from "./webhookQueue";
|
||||
import { EntityChangeQueue } from "./entityChangeQueue";
|
||||
import { DatasetDeleteQueue } from "./datasetDelete";
|
||||
|
||||
// IngestionQueue is sharded and requires a sharding key
|
||||
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
|
||||
@@ -36,6 +37,8 @@ export function getQueue(
|
||||
return CloudUsageMeteringQueue.getInstance();
|
||||
case QueueName.DatasetRunItemUpsert:
|
||||
return DatasetRunItemUpsertQueue.getInstance();
|
||||
case QueueName.DatasetDelete:
|
||||
return DatasetDeleteQueue.getInstance();
|
||||
case QueueName.EvaluationExecution:
|
||||
return EvalExecutionQueue.getInstance();
|
||||
case QueueName.ExperimentCreate:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { QueueName, TQueueJobTypes } from "../queues";
|
||||
import { Queue } from "bullmq";
|
||||
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
|
||||
import {
|
||||
createNewRedisInstance,
|
||||
getQueuePrefix,
|
||||
redisQueueRetryOptions,
|
||||
} from "./redis";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class WebhookQueue {
|
||||
@@ -23,6 +27,7 @@ export class WebhookQueue {
|
||||
QueueName.WebhookQueue,
|
||||
{
|
||||
connection: newRedis,
|
||||
prefix: getQueuePrefix(QueueName.WebhookQueue),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100_000,
|
||||
|
||||
@@ -2,17 +2,22 @@ import {
|
||||
Action,
|
||||
ActionExecutionStatus,
|
||||
JobConfigState,
|
||||
Prisma,
|
||||
prisma,
|
||||
Trigger,
|
||||
} from "../../db";
|
||||
import {
|
||||
TriggerEventSource,
|
||||
WebhookActionConfigWithSecrets,
|
||||
TriggerDomain,
|
||||
TriggerEventAction,
|
||||
ActionDomain,
|
||||
AutomationDomain,
|
||||
SafeWebhookActionConfig,
|
||||
ActionDomainWithSecrets,
|
||||
SafeActionConfig,
|
||||
isWebhookActionConfig,
|
||||
WebhookActionConfigWithSecrets,
|
||||
isSafeWebhookActionConfig,
|
||||
convertToSafeWebhookConfig,
|
||||
} from "../../domain/automations";
|
||||
import { FilterState } from "../../types";
|
||||
import { decryptSecretHeaders, mergeHeaders } from "../utils/headerUtils";
|
||||
@@ -23,7 +28,7 @@ export const getActionByIdWithSecrets = async ({
|
||||
}: {
|
||||
projectId: string;
|
||||
actionId: string;
|
||||
}) => {
|
||||
}): Promise<ActionDomainWithSecrets | null> => {
|
||||
const actionConfig = await prisma.action.findFirst({
|
||||
where: {
|
||||
id: actionId,
|
||||
@@ -35,31 +40,41 @@ export const getActionByIdWithSecrets = async ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = actionConfig.config as WebhookActionConfigWithSecrets;
|
||||
if (isWebhookActionConfig(actionConfig.config)) {
|
||||
const config = actionConfig.config; // Type guard ensures this is WebhookActionConfigWithSecrets
|
||||
|
||||
// Decrypt secret headers for webhook execution using new structure
|
||||
const decryptedHeaders = config.requestHeaders
|
||||
? decryptSecretHeaders(mergeHeaders(config.headers, config.requestHeaders))
|
||||
: Object.entries(config.headers).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = { secret: false, value };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { secret: boolean; value: string }>,
|
||||
);
|
||||
// Decrypt secret headers for webhook execution using new structure
|
||||
const decryptedHeaders = config.requestHeaders
|
||||
? decryptSecretHeaders(
|
||||
mergeHeaders(config.headers, config.requestHeaders),
|
||||
)
|
||||
: config.headers
|
||||
? Object.entries(config.headers).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = { secret: false, value };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { secret: boolean; value: string }>,
|
||||
)
|
||||
: {};
|
||||
|
||||
return {
|
||||
...actionConfig,
|
||||
config: {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
requestHeaders: decryptedHeaders,
|
||||
displayHeaders: config.displayHeaders,
|
||||
apiVersion: config.apiVersion,
|
||||
displaySecretKey: config.displaySecretKey,
|
||||
secretKey: config.secretKey,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...actionConfig,
|
||||
config: {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
requestHeaders: decryptedHeaders,
|
||||
displayHeaders: getDisplayHeaders(config),
|
||||
apiVersion: config.apiVersion,
|
||||
displaySecretKey: config.displaySecretKey,
|
||||
secretKey: config.secretKey,
|
||||
lastFailingExecutionId: config.lastFailingExecutionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For SLACK and others, return as stored (already safe)
|
||||
return actionConfig as ActionDomainWithSecrets;
|
||||
};
|
||||
|
||||
export const getActionById = async ({
|
||||
@@ -128,10 +143,7 @@ const convertTriggerToDomain = (trigger: Trigger): TriggerDomain => {
|
||||
};
|
||||
};
|
||||
|
||||
const convertActionToDomain = (action: Action): ActionDomain => {
|
||||
const config = action.config as WebhookActionConfigWithSecrets;
|
||||
|
||||
// Handle legacy headers - convert them to displayHeaders format if displayHeaders is undefined
|
||||
const getDisplayHeaders = (config: WebhookActionConfigWithSecrets) => {
|
||||
let displayHeaders = config.displayHeaders;
|
||||
if (!displayHeaders && config.headers) {
|
||||
// Convert legacy headers to displayHeaders format
|
||||
@@ -143,17 +155,25 @@ const convertActionToDomain = (action: Action): ActionDomain => {
|
||||
{} as Record<string, { secret: boolean; value: string }>,
|
||||
);
|
||||
}
|
||||
return displayHeaders;
|
||||
};
|
||||
|
||||
const convertActionToDomain = (action: Action): ActionDomain => {
|
||||
if (isWebhookActionConfig(action.config)) {
|
||||
const config = action.config;
|
||||
config.displayHeaders = getDisplayHeaders(config);
|
||||
|
||||
return {
|
||||
...action,
|
||||
config: convertToSafeWebhookConfig(config),
|
||||
};
|
||||
}
|
||||
|
||||
// For SLACK (or future types) return config as-is
|
||||
return {
|
||||
...action,
|
||||
config: {
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
displayHeaders,
|
||||
apiVersion: config.apiVersion,
|
||||
displaySecretKey: config.displaySecretKey,
|
||||
} as SafeWebhookActionConfig,
|
||||
};
|
||||
config: action.config as SafeActionConfig,
|
||||
} as ActionDomain;
|
||||
};
|
||||
|
||||
export const getAutomationById = async ({
|
||||
@@ -225,28 +245,49 @@ export const getConsecutiveAutomationFailures = async ({
|
||||
automationId: string;
|
||||
projectId: string;
|
||||
}): Promise<number> => {
|
||||
// First get the automation to extract triggerId and actionId
|
||||
const automation = await prisma.automation.findFirst({
|
||||
where: {
|
||||
id: automationId,
|
||||
projectId,
|
||||
},
|
||||
const automation = await getAutomationById({
|
||||
automationId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!automation) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { triggerId, actionId } = automation;
|
||||
const executions = await prisma.automationExecution.findMany({
|
||||
where: {
|
||||
triggerId,
|
||||
actionId,
|
||||
projectId,
|
||||
status: {
|
||||
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
|
||||
},
|
||||
// Build where clause - if lastFailingExecutionId is set, only consider executions newer than it
|
||||
const whereClause: Prisma.AutomationExecutionWhereInput = {
|
||||
triggerId: automation.trigger.id,
|
||||
actionId: automation.action.id,
|
||||
projectId,
|
||||
status: {
|
||||
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
|
||||
},
|
||||
};
|
||||
|
||||
// If there's a lastFailingExecutionId, we need to get executions that are newer than that execution
|
||||
if (
|
||||
isSafeWebhookActionConfig(automation.action.config) &&
|
||||
automation.action.config.lastFailingExecutionId
|
||||
) {
|
||||
// First get the timestamp of the last failing execution
|
||||
const lastFailingExecution = await prisma.automationExecution.findUnique({
|
||||
where: {
|
||||
id: automation.action.config.lastFailingExecutionId,
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (lastFailingExecution) {
|
||||
whereClause.createdAt = {
|
||||
gt: lastFailingExecution.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const executions = await prisma.automationExecution.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
|
||||
import { type OrderByState } from "../../interfaces/orderBy";
|
||||
import { datasetRunItemsTableUiColumnDefinitions } from "../../tableDefinitions";
|
||||
import { FilterState } from "../../types";
|
||||
import {
|
||||
createFilterFromFilterState,
|
||||
FilterList,
|
||||
orderByToClickhouseSql,
|
||||
StringFilter,
|
||||
} from "../queries";
|
||||
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,
|
||||
) => {
|
||||
return {
|
||||
datasetRunItemsFilter: new FilterList([
|
||||
new StringFilter({
|
||||
clickhouseTable: "dataset_run_items",
|
||||
field: "project_id",
|
||||
operator: "=",
|
||||
value: projectId,
|
||||
}),
|
||||
new StringFilter({
|
||||
clickhouseTable: "dataset_run_items",
|
||||
field: "dataset_id",
|
||||
operator: "=",
|
||||
value: datasetId,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
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";
|
||||
tags: Record<string, string>;
|
||||
},
|
||||
): Promise<Array<T>> => {
|
||||
const { projectId, datasetId, filter, orderBy, limit, offset } = opts;
|
||||
|
||||
let selectString = "";
|
||||
|
||||
switch (opts.select) {
|
||||
case "count":
|
||||
selectString =
|
||||
"count(DISTINCT dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id) as count";
|
||||
break;
|
||||
case "rows":
|
||||
selectString = `
|
||||
dri.id as id,
|
||||
dri.project_id as project_id,
|
||||
dri.trace_id as trace_id,
|
||||
dri.observation_id as observation_id,
|
||||
dri.dataset_id as dataset_id,
|
||||
dri.dataset_run_id as dataset_run_id,
|
||||
dri.dataset_item_id as dataset_item_id,
|
||||
dri.error as error,
|
||||
dri.created_at as created_at,
|
||||
dri.updated_at as updated_at,
|
||||
dri.dataset_run_name as dataset_run_name,
|
||||
dri.dataset_run_description as dataset_run_description,
|
||||
dri.dataset_run_metadata as dataset_run_metadata,
|
||||
dri.dataset_run_created_at as dataset_run_created_at,
|
||||
dri.dataset_item_input as dataset_item_input,
|
||||
dri.dataset_item_expected_output as dataset_item_expected_output,
|
||||
dri.dataset_item_metadata as dataset_item_metadata,
|
||||
dri.is_deleted as is_deleted,
|
||||
dri.event_ts as event_ts`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown select type: ${opts.select}`);
|
||||
}
|
||||
|
||||
const { datasetRunItemsFilter } = getProjectDatasetIdDefaultFilter(
|
||||
projectId,
|
||||
datasetId,
|
||||
);
|
||||
|
||||
datasetRunItemsFilter.push(
|
||||
...createFilterFromFilterState(
|
||||
filter,
|
||||
datasetRunItemsTableUiColumnDefinitions,
|
||||
),
|
||||
);
|
||||
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) {
|
||||
if (Array.isArray(orderBy)) {
|
||||
orderByArray.push(...orderBy);
|
||||
} else {
|
||||
orderByArray.push(orderBy);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event_ts DESC for row queries (for deduplication)
|
||||
if (opts.select === "rows") {
|
||||
orderByArray.push({
|
||||
column: "eventTs",
|
||||
order: "DESC",
|
||||
});
|
||||
}
|
||||
|
||||
const orderByClause = orderByToClickhouseSql(
|
||||
orderByArray,
|
||||
datasetRunItemsTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${selectString}
|
||||
FROM dataset_run_items dri
|
||||
WHERE ${appliedFilter.query}
|
||||
${orderByClause}
|
||||
${opts.select === "rows" ? "LIMIT 1 BY dri.project_id, dri.dataset_id, dri.dataset_run_id, dri.dataset_item_id" : ""}
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query,
|
||||
params: {
|
||||
...appliedFilter.params,
|
||||
},
|
||||
tags: {
|
||||
...(opts.tags ?? {}),
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
projectId,
|
||||
datasetId,
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsByDatasetIdCh = async (
|
||||
opts: DatasetRunItemsTableQuery,
|
||||
): Promise<DatasetRunItemDomain[]> => {
|
||||
const rows =
|
||||
await getDatasetRunItemsTableInternal<DatasetRunItemRecordReadType>({
|
||||
...opts,
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
return rows.map(convertDatasetRunItemClickhouseToDomain);
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsCountByDatasetIdCh = async (
|
||||
opts: DatasetRunItemsTableQuery,
|
||||
): Promise<number> => {
|
||||
const rows = await getDatasetRunItemsTableInternal<{ count: string }>({
|
||||
...opts,
|
||||
select: "count",
|
||||
tags: { kind: "list" },
|
||||
});
|
||||
|
||||
return Number(rows[0]?.count);
|
||||
};
|
||||
|
||||
export const deleteDatasetRunItemsByProjectId = async ({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) => {
|
||||
const query = `
|
||||
DELETE FROM dataset_run_items
|
||||
WHERE project_id = {projectId: String};
|
||||
`;
|
||||
await commandClickhouse({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteDatasetRunItemsByDatasetId = async ({
|
||||
projectId,
|
||||
datasetId,
|
||||
}: {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
}) => {
|
||||
const query = `
|
||||
DELETE FROM dataset_run_items
|
||||
WHERE project_id = {projectId: String}
|
||||
AND dataset_id = {datasetId: String}
|
||||
`;
|
||||
|
||||
await commandClickhouse({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
datasetId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
kind: "delete",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteDatasetRunItemsByDatasetRunIds = async ({
|
||||
projectId,
|
||||
datasetRunIds,
|
||||
datasetId,
|
||||
}: {
|
||||
projectId: string;
|
||||
datasetRunIds: string[];
|
||||
datasetId: string;
|
||||
}) => {
|
||||
const query = `
|
||||
DELETE FROM dataset_run_items
|
||||
WHERE project_id = {projectId: String}
|
||||
AND dataset_id = {datasetId: String}
|
||||
AND dataset_run_id IN ({datasetRunIds: Array(String)})
|
||||
`;
|
||||
|
||||
await commandClickhouse({
|
||||
query,
|
||||
params: {
|
||||
projectId,
|
||||
datasetRunIds,
|
||||
datasetId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "datasets",
|
||||
type: "dataset-run-items",
|
||||
kind: "delete",
|
||||
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(),
|
||||
@@ -355,31 +357,37 @@ export const convertPostgresDatasetRunItemToInsert = (
|
||||
project_id: datasetRunItem.project_id,
|
||||
dataset_run_id: datasetRunItem.dataset_run_id,
|
||||
dataset_item_id: datasetRunItem.dataset_item_id,
|
||||
dataset_id: datasetRunItem.dataset_id,
|
||||
trace_id: datasetRunItem.trace_id,
|
||||
observation_id: datasetRunItem.observation_id,
|
||||
error: datasetRunItem.error,
|
||||
created_at: datasetRunItem.created_at?.getTime(),
|
||||
updated_at: datasetRunItem.updated_at?.getTime(),
|
||||
// denormalized run data
|
||||
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
|
||||
dataset_id: datasetRunItem.dataset_id,
|
||||
dataset_run_name: datasetRunItem.dataset_run_name,
|
||||
dataset_run_description: datasetRunItem.dataset_run_description,
|
||||
dataset_run_metadata:
|
||||
typeof datasetRunItem.dataset_run_metadata === "string"
|
||||
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
|
||||
typeof datasetRunItem.dataset_run_metadata === "string" ||
|
||||
typeof datasetRunItem.dataset_run_metadata === "number" ||
|
||||
typeof datasetRunItem.dataset_run_metadata === "boolean"
|
||||
? { metadata: datasetRunItem.dataset_run_metadata }
|
||||
: Array.isArray(datasetRunItem.dataset_run_metadata)
|
||||
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
|
||||
: datasetRunItem.dataset_run_metadata,
|
||||
? { metadata: datasetRunItem.dataset_run_metadata }
|
||||
: (datasetRunItem.dataset_run_metadata ?? {}),
|
||||
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
|
||||
// denormalized item data
|
||||
dataset_item_input: datasetRunItem.dataset_item_input,
|
||||
dataset_item_expected_output: datasetRunItem.dataset_item_expected_output,
|
||||
dataset_item_input: JSON.stringify(datasetRunItem.dataset_item_input),
|
||||
dataset_item_expected_output: JSON.stringify(
|
||||
datasetRunItem.dataset_item_expected_output,
|
||||
),
|
||||
dataset_item_metadata:
|
||||
typeof datasetRunItem.dataset_item_metadata === "string"
|
||||
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
|
||||
typeof datasetRunItem.dataset_item_metadata === "string" ||
|
||||
typeof datasetRunItem.dataset_item_metadata === "number" ||
|
||||
typeof datasetRunItem.dataset_item_metadata === "boolean"
|
||||
? { metadata: datasetRunItem.dataset_item_metadata }
|
||||
: Array.isArray(datasetRunItem.dataset_item_metadata)
|
||||
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
|
||||
: datasetRunItem.dataset_item_metadata,
|
||||
? { metadata: datasetRunItem.dataset_item_metadata }
|
||||
: (datasetRunItem.dataset_item_metadata ?? {}),
|
||||
event_ts: datasetRunItem.created_at?.getTime(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
@@ -489,9 +497,9 @@ export const convertPostgresScoreToInsert = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertTraceToTraceMt = (
|
||||
export const convertTraceToTraceNull = (
|
||||
traceRecord: TraceRecordInsertType,
|
||||
): TraceMtRecordInsertType => {
|
||||
): TraceNullRecordInsertType => {
|
||||
return {
|
||||
// Identifiers
|
||||
project_id: traceRecord.project_id,
|
||||
@@ -529,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,
|
||||
@@ -573,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
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./blobStorageLog";
|
||||
export * from "./environments";
|
||||
export * from "./automation-repository";
|
||||
export * from "./dataset-run-items-converters";
|
||||
export * from "./dataset-run-items";
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -35,6 +35,12 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
|
||||
|
||||
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("PIVOT_TABLE"),
|
||||
defaultSort: z
|
||||
.object({
|
||||
column: z.string(),
|
||||
order: z.enum(["ASC", "DESC"]),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Define dimension schema
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Slack Integration Service
|
||||
*
|
||||
* Simplified service that properly uses the official Slack SDK libraries:
|
||||
* - @slack/oauth InstallProvider for OAuth flow management
|
||||
* - @slack/web-api WebClient for Slack API operations
|
||||
* - Metadata-based project-to-team mapping
|
||||
*/
|
||||
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { InstallProvider } from "@slack/oauth";
|
||||
import { logger } from "../logger";
|
||||
import { env } from "../../env";
|
||||
import { prisma } from "../../db";
|
||||
import { encrypt, decrypt } from "../../encryption";
|
||||
|
||||
// Types for Slack integration
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export interface SlackMessageParams {
|
||||
client: WebClient;
|
||||
channelId: string;
|
||||
blocks: any[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SlackMessageResponse {
|
||||
messageTs: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
// Interface for Slack installation metadata
|
||||
export interface SlackInstallationMetadata {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate Slack installation metadata
|
||||
*/
|
||||
function isSlackInstallationMetadata(
|
||||
metadata: unknown,
|
||||
): metadata is SlackInstallationMetadata {
|
||||
return (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
"projectId" in metadata &&
|
||||
typeof metadata.projectId === "string" &&
|
||||
metadata.projectId.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse and validate Slack installation metadata
|
||||
*/
|
||||
export function parseSlackInstallationMetadata(
|
||||
metadata: unknown,
|
||||
): SlackInstallationMetadata {
|
||||
if (typeof metadata !== "string") {
|
||||
throw new Error("Installation metadata must be a string");
|
||||
}
|
||||
|
||||
let parsedMetadata: unknown;
|
||||
try {
|
||||
parsedMetadata = JSON.parse(metadata);
|
||||
} catch {
|
||||
throw new Error("Failed to parse installation metadata as JSON");
|
||||
}
|
||||
|
||||
if (!isSlackInstallationMetadata(parsedMetadata)) {
|
||||
throw new Error(
|
||||
"Invalid installation metadata: missing or invalid projectId",
|
||||
);
|
||||
}
|
||||
|
||||
return parsedMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack Service Class
|
||||
*
|
||||
* Uses InstallProvider for OAuth flow and metadata-based project mapping.
|
||||
* Much simpler than the previous implementation while maintaining all functionality.
|
||||
*/
|
||||
export class SlackService {
|
||||
private static instance: SlackService | null = null;
|
||||
private installer: InstallProvider;
|
||||
|
||||
private constructor() {
|
||||
this.installer = new InstallProvider({
|
||||
clientId: env.SLACK_CLIENT_ID!,
|
||||
clientSecret: env.SLACK_CLIENT_SECRET!,
|
||||
stateSecret: env.SLACK_STATE_SECRET!,
|
||||
installUrlOptions: {
|
||||
scopes: ["channels:read", "chat:write", "chat:write.public"],
|
||||
},
|
||||
installationStore: {
|
||||
storeInstallation: async (installation) => {
|
||||
try {
|
||||
const metadata = parseSlackInstallationMetadata(
|
||||
installation.metadata,
|
||||
);
|
||||
const projectId = metadata.projectId;
|
||||
|
||||
logger.info("Storing Slack installation for project", {
|
||||
projectId,
|
||||
teamId: installation.team?.id,
|
||||
teamName: installation.team?.name,
|
||||
});
|
||||
|
||||
// Store by projectId (one integration per project)
|
||||
await prisma.slackIntegration.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
teamId: installation.team?.id!,
|
||||
teamName: installation.team?.name!,
|
||||
botToken: encrypt(installation.bot?.token!),
|
||||
botUserId: installation.bot?.userId!,
|
||||
},
|
||||
update: {
|
||||
teamId: installation.team?.id!,
|
||||
teamName: installation.team?.name!,
|
||||
botToken: encrypt(installation.bot?.token!),
|
||||
botUserId: installation.bot?.userId!,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Slack installation stored successfully", {
|
||||
projectId,
|
||||
teamId: installation.team?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to store Slack installation", { error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
fetchInstallation: async (installQuery) => {
|
||||
try {
|
||||
// Handle both teamId and projectId lookups
|
||||
// When SDK calls with teamId, we treat it as projectId
|
||||
const lookupId = installQuery.teamId;
|
||||
|
||||
if (!lookupId) {
|
||||
throw new Error("No lookup ID provided");
|
||||
}
|
||||
|
||||
const integration = await prisma.slackIntegration.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ teamId: lookupId }, // Actual team ID lookup
|
||||
{ projectId: lookupId }, // Project ID lookup (our custom usage)
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new Error("Slack integration not found");
|
||||
}
|
||||
|
||||
// Return full Installation interface as expected by SDK
|
||||
return {
|
||||
team: {
|
||||
id: integration.teamId,
|
||||
name: integration.teamName,
|
||||
},
|
||||
bot: {
|
||||
id: integration.botUserId,
|
||||
token: decrypt(integration.botToken),
|
||||
userId: integration.botUserId,
|
||||
scopes: [],
|
||||
},
|
||||
enterprise: undefined,
|
||||
user: {
|
||||
token: undefined,
|
||||
refreshToken: undefined,
|
||||
expiresAt: undefined,
|
||||
scopes: undefined,
|
||||
id: integration.botUserId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch Slack installation", { error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteInstallation: async (installQuery) => {
|
||||
try {
|
||||
const lookupId = installQuery.teamId;
|
||||
|
||||
if (!lookupId) {
|
||||
throw new Error("No lookup ID provided for deletion");
|
||||
}
|
||||
|
||||
await prisma.slackIntegration.deleteMany({
|
||||
where: {
|
||||
OR: [{ teamId: lookupId }, { projectId: lookupId }],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Slack installation deleted successfully", {
|
||||
lookupId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete Slack installation", { error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance of SlackService
|
||||
*/
|
||||
static getInstance(): SlackService {
|
||||
if (!SlackService.instance) {
|
||||
SlackService.instance = new SlackService();
|
||||
}
|
||||
return SlackService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured InstallProvider instance for OAuth handling
|
||||
*/
|
||||
getInstaller(): InstallProvider {
|
||||
return this.installer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
SlackService.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Slack integration for a project
|
||||
*/
|
||||
async deleteIntegration(projectId: string): Promise<void> {
|
||||
try {
|
||||
if (!this.installer.installationStore?.deleteInstallation) {
|
||||
throw new Error("Installation store not configured");
|
||||
}
|
||||
|
||||
await this.installer.installationStore.deleteInstallation({
|
||||
teamId: projectId,
|
||||
isEnterpriseInstall: false,
|
||||
enterpriseId: undefined,
|
||||
});
|
||||
|
||||
logger.info("Slack integration deleted for project", { projectId });
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete Slack integration", { error, projectId });
|
||||
throw new Error(
|
||||
`Failed to delete integration: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebClient for a specific project
|
||||
*/
|
||||
async getWebClientForProject(projectId: string): Promise<WebClient> {
|
||||
try {
|
||||
// Use projectId as the teamId parameter (handled by our fetchInstallation)
|
||||
const auth = await this.installer.authorize({
|
||||
teamId: projectId,
|
||||
isEnterpriseInstall: false,
|
||||
enterpriseId: undefined,
|
||||
});
|
||||
|
||||
if (!auth.botToken) {
|
||||
throw new Error("No bot token found for project");
|
||||
}
|
||||
|
||||
const client = new WebClient(auth.botToken);
|
||||
logger.debug("Created WebClient for project", { projectId });
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
logger.error("Failed to create WebClient for project", {
|
||||
error,
|
||||
projectId,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to create WebClient: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels accessible to the bot
|
||||
*/
|
||||
async getChannels(client: WebClient): Promise<SlackChannel[]> {
|
||||
try {
|
||||
const result = await client.conversations.list({
|
||||
exclude_archived: true,
|
||||
types: "public_channel",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Slack API error: ${result.error}`);
|
||||
}
|
||||
|
||||
const channels: SlackChannel[] = (result.channels || []).map(
|
||||
(channel) => ({
|
||||
id: channel.id!,
|
||||
name: channel.name!,
|
||||
isPrivate: channel.is_private || false,
|
||||
isMember: channel.is_member || false,
|
||||
}),
|
||||
);
|
||||
|
||||
logger.debug("Retrieved channels from Slack", {
|
||||
channelCount: channels.length,
|
||||
});
|
||||
|
||||
return channels;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch channels", { error });
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Slack channel
|
||||
*/
|
||||
async sendMessage(params: SlackMessageParams): Promise<SlackMessageResponse> {
|
||||
try {
|
||||
const result = await params.client.chat.postMessage({
|
||||
channel: params.channelId,
|
||||
blocks: params.blocks,
|
||||
text: params.text || "Langfuse Notification",
|
||||
unfurl_links: false,
|
||||
unfurl_media: false,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to send message: ${result.error}`);
|
||||
}
|
||||
|
||||
const response = {
|
||||
messageTs: result.ts!,
|
||||
channel: result.channel!,
|
||||
};
|
||||
|
||||
logger.info("Message sent successfully to Slack", {
|
||||
channel: params.channelId,
|
||||
messageTs: response.messageTs,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error("Failed to send message", {
|
||||
error,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a WebClient instance
|
||||
*/
|
||||
async validateClient(client: WebClient): Promise<boolean> {
|
||||
try {
|
||||
const result = await client.auth.test();
|
||||
return result.ok || false;
|
||||
} catch (error) {
|
||||
logger.warn("Client validation failed", { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
Row,
|
||||
Column,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface BillingAlertEmailProps {
|
||||
organizationName: string;
|
||||
currentUsage: number;
|
||||
threshold: number;
|
||||
billingUrl: string;
|
||||
receiverEmail: string;
|
||||
}
|
||||
|
||||
export const BillingAlertEmailTemplate = ({
|
||||
organizationName,
|
||||
currentUsage,
|
||||
threshold,
|
||||
billingUrl,
|
||||
receiverEmail,
|
||||
}: BillingAlertEmailProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
Your Langfuse Cloud usage is {`${currentUsage}`} events for the current
|
||||
billing period
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-background my-auto mx-auto font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] rounded border border-solid border-[#eaeaea] p-5">
|
||||
<Section className="mt-8">
|
||||
<Img
|
||||
src="https://static.langfuse.com/langfuse_logo_transactional_email.png"
|
||||
width="40"
|
||||
height="40"
|
||||
alt="Langfuse"
|
||||
className="mx-auto my-0"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-2xl font-normal text-black">
|
||||
Usage Threshold Exceeded
|
||||
</Heading>
|
||||
<Text className="text-gray-700 text-sm leading-6">
|
||||
Your organization "{organizationName}" has exceeded
|
||||
the configured billing threshold
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="mt-8">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<Row>
|
||||
<Column className="text-center">
|
||||
<Text className="text-gray-600 text-sm font-medium m-0 mb-1">
|
||||
Current Usage (# Events)
|
||||
</Text>
|
||||
<Text className="text-2xl font-bold text-gray-900 m-0">
|
||||
{currentUsage}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="text-center">
|
||||
<Text className="text-gray-600 text-sm font-medium m-0 mb-1">
|
||||
Alert Threshold (# Events)
|
||||
</Text>
|
||||
<Text className="text-2xl font-bold text-gray-900 m-0">
|
||||
{threshold}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section className="mt-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
|
||||
href={billingUrl}
|
||||
>
|
||||
View Billing Page and Manage Alerts
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Section className="mt-8">
|
||||
<Heading className="text-black text-[18px] font-semibold">
|
||||
What happens next?
|
||||
</Heading>
|
||||
<Text className="text-gray-700 text-sm leading-6">
|
||||
• Your current billing cycle continues normally
|
||||
<br />
|
||||
• Charges will appear on your next invoice
|
||||
<br />
|
||||
• You can adjust usage or modify alert thresholds
|
||||
<br />• Contact support if you have questions about your bill
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
|
||||
|
||||
<Section>
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
This email was sent to {receiverEmail} regarding billing alerts
|
||||
for "{organizationName}".
|
||||
</Text>
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
Questions? Contact us at{" "}
|
||||
<a
|
||||
href="mailto:support@langfuse.com"
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
support@langfuse.com
|
||||
</a>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingAlertEmailTemplate;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
import { parseConnectionUrl } from "nodemailer/lib/shared/index.js";
|
||||
import { render } from "@react-email/render";
|
||||
import { BillingAlertEmailTemplate } from "./BillingAlertEmailTemplate";
|
||||
import { logger } from "../../../logger";
|
||||
|
||||
export interface BillingAlertEmailProps {
|
||||
env: Partial<
|
||||
Record<"EMAIL_FROM_ADDRESS" | "SMTP_CONNECTION_URL", string | undefined>
|
||||
>;
|
||||
organizationName: string;
|
||||
currentUsage: number;
|
||||
threshold: number;
|
||||
billingUrl: string;
|
||||
receiverEmail: string;
|
||||
}
|
||||
|
||||
export const sendBillingAlertEmail = async ({
|
||||
env,
|
||||
organizationName,
|
||||
currentUsage,
|
||||
threshold,
|
||||
billingUrl,
|
||||
receiverEmail,
|
||||
}: BillingAlertEmailProps) => {
|
||||
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
|
||||
logger.error(
|
||||
"Missing environment variables for sending billing alert email.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
|
||||
|
||||
const emailSubject = `Langfuse Cloud Billing Alert: ${organizationName} usage exceeded ${threshold} events`;
|
||||
const emailHtml = await render(
|
||||
BillingAlertEmailTemplate({
|
||||
organizationName,
|
||||
currentUsage,
|
||||
threshold,
|
||||
billingUrl,
|
||||
receiverEmail,
|
||||
}),
|
||||
);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: receiverEmail,
|
||||
from: {
|
||||
address: env.EMAIL_FROM_ADDRESS,
|
||||
name: "Langfuse",
|
||||
},
|
||||
subject: emailSubject,
|
||||
html: emailHtml,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send billing alert email`, error);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
|
||||
import { FilterState } from "../../types";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
|
||||
import {
|
||||
getProjectIdDefaultFilter,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import {
|
||||
TRACE_TO_OBSERVATIONS_INTERVAL,
|
||||
queryClickhouse,
|
||||
getTimeframesTracesAMT,
|
||||
} from "../repositories";
|
||||
|
||||
export type SessionDataReturnType = {
|
||||
@@ -22,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 & {
|
||||
@@ -157,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;
|
||||
@@ -165,7 +171,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
const { tracesFilter, scoresFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
tracesPrefix: "s",
|
||||
});
|
||||
|
||||
@@ -174,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) =>
|
||||
@@ -193,20 +200,25 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const additionalSingleTraceFilter = tracesFilter.find(
|
||||
(f) =>
|
||||
f.field === "bookmarked" ||
|
||||
f.field === "session_id" ||
|
||||
f.field === "environment",
|
||||
);
|
||||
|
||||
if (additionalSingleTraceFilter) {
|
||||
filters.push(additionalSingleTraceFilter);
|
||||
}
|
||||
tracesFilter
|
||||
.filter(
|
||||
(f) =>
|
||||
f.field === "bookmarked" ||
|
||||
f.field === "session_id" ||
|
||||
f.field === "environment",
|
||||
)
|
||||
.forEach((f) => filters.push(f));
|
||||
|
||||
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,115 +247,188 @@ 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 (
|
||||
SELECT * EXCEPT input, output, metadata
|
||||
FROM traces t
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
deduplicated_observations AS (
|
||||
SELECT *
|
||||
FROM observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
AND o.trace_id IN (
|
||||
SELECT id
|
||||
FROM deduplicated_traces
|
||||
)
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM deduplicated_observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags,
|
||||
anyLast(t.environment) as trace_environment
|
||||
-- Aggregate observations data at session level
|
||||
${
|
||||
selectMetrics
|
||||
? `
|
||||
,
|
||||
sum(o.obs_count) as total_observations,
|
||||
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
|
||||
-- LLM session started on that date so this behaviour should yield better results.
|
||||
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,
|
||||
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,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage`
|
||||
: ""
|
||||
}
|
||||
FROM deduplicated_traces t
|
||||
${
|
||||
selectMetrics
|
||||
? `LEFT JOIN observations_agg o
|
||||
ON t.id = o.trace_id AND t.project_id = o.project_id`
|
||||
: ""
|
||||
}
|
||||
WHERE t.session_id IS NOT NULL
|
||||
WITH ${select === "metrics" || requiresScoresJoin ? `${scoresCte},` : ""}
|
||||
deduplicated_traces AS (
|
||||
SELECT * EXCEPT input, output, metadata
|
||||
FROM __TRACE_TABLE__ t
|
||||
WHERE t.session_id IS NOT NULL
|
||||
AND t.project_id = {projectId: String}
|
||||
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
deduplicated_observations AS (
|
||||
SELECT *
|
||||
FROM observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
AND o.trace_id IN (
|
||||
SELECT id
|
||||
FROM deduplicated_traces
|
||||
)
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 BY id, project_id
|
||||
),
|
||||
observations_agg AS (
|
||||
SELECT o.trace_id,
|
||||
count(*) as obs_count,
|
||||
min(o.start_time) as min_start_time,
|
||||
max(o.end_time) as max_end_time,
|
||||
sumMap(usage_details) as sum_usage_details,
|
||||
sumMap(cost_details) as sum_cost_details,
|
||||
anyLast(project_id) as project_id
|
||||
FROM deduplicated_observations o
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
|
||||
GROUP BY o.trace_id
|
||||
),
|
||||
session_data AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
anyLast(t.project_id) as project_id,
|
||||
max(t.timestamp) as max_timestamp,
|
||||
min(t.timestamp) as min_timestamp,
|
||||
groupArray(t.id) AS trace_ids,
|
||||
groupUniqArray(t.user_id) AS user_ids,
|
||||
count(*) as trace_count,
|
||||
groupUniqArrayArray(t.tags) as trace_tags,
|
||||
anyLast(t.environment) as trace_environment
|
||||
-- Aggregate observations data at session level
|
||||
${
|
||||
selectMetrics
|
||||
? `,
|
||||
sum(o.obs_count) as total_observations,
|
||||
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
|
||||
-- LLM session started on that date so this behaviour should yield better results.
|
||||
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,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
|
||||
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
|
||||
sumMap(o.sum_usage_details)['total'] as session_total_usage`
|
||||
: ""
|
||||
}
|
||||
FROM deduplicated_traces t
|
||||
${
|
||||
selectMetrics
|
||||
? `LEFT JOIN observations_agg o
|
||||
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}` : ""}
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
FROM session_data s
|
||||
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
|
||||
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const obsStartTimeValue = traceTimestampFilter
|
||||
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
|
||||
: null;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...(obsStartTimeValue
|
||||
? { observationsStartTime: obsStartTimeValue }
|
||||
: {}),
|
||||
return measureAndReturn({
|
||||
operationName: "getSessionsTableGeneric",
|
||||
projectId,
|
||||
minStartTime: filter?.find(
|
||||
(f) =>
|
||||
f.column === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
...tracesFilterRes.params,
|
||||
...singleTraceFilter?.params,
|
||||
...scoresFilterRes.params,
|
||||
...(traceTimestampFilter
|
||||
? {
|
||||
observationsStartTime: convertDateToClickhouseDateTime(
|
||||
traceTimestampFilter.value,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "sessions-table",
|
||||
projectId,
|
||||
operation_name: "getSessionsTableGeneric",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "sessions-table",
|
||||
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) => {
|
||||
// Extract the timestamp from filter for AMT table selection
|
||||
const fromTimestamp = filter?.find(
|
||||
(f) =>
|
||||
f.column === "min_timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined;
|
||||
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
|
||||
return queryClickhouse<T>({
|
||||
query: query.replace("__TRACE_TABLE__", traceAmt),
|
||||
params: input.params,
|
||||
tags: { ...input.tags, experiment_amt: "new" },
|
||||
clickhouseConfigs,
|
||||
});
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./mapTracesTable";
|
||||
export * from "./mapDashboards";
|
||||
export * from "./mapScoresTable";
|
||||
export * from "./promptsTable";
|
||||
export * from "./mapDatasetRunItemsTable";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { UiColumnMappings } from "./types";
|
||||
|
||||
export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
|
||||
{
|
||||
uiTableName: "Dataset Run ID",
|
||||
uiTableId: "datasetRunId",
|
||||
clickhouseTableName: "dataset_run_items",
|
||||
clickhouseSelect: 'dri."dataset_run_id"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Created At",
|
||||
uiTableId: "createdAt",
|
||||
clickhouseTableName: "dataset_run_items",
|
||||
clickhouseSelect: 'dri."created_at"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Event Timestamp",
|
||||
uiTableId: "eventTs",
|
||||
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];
|
||||
|
||||
@@ -7,7 +7,7 @@ export type FilterCondition = z.infer<typeof singleFilter>;
|
||||
export type FilterState = FilterCondition[];
|
||||
|
||||
// to be used in the client during editing
|
||||
type MakeOptional<T> = {
|
||||
export type MakeOptional<T> = {
|
||||
[K in keyof T]?: T[K];
|
||||
};
|
||||
// if key is value, add string as value
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export const paginationMetaResponseZod = z.object({
|
||||
totalPages: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/i;
|
||||
export const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/i;
|
||||
export const noUrlCheck = (value: string) => !urlRegex.test(value);
|
||||
|
||||
export const NonEmptyString = z.string().min(1);
|
||||
|
||||
Generated
+338
-300
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
|
||||
RUN npm install turbo@^2.5.4 --global
|
||||
RUN npm install turbo@^2.5.5 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
+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;` : ""}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.85.2",
|
||||
"version": "3.96.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8,7 +8,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "INLINE_RUNTIME_CHUNK=false dotenv -e ../.env -- next build",
|
||||
"dev": "dotenv -e ../.env -- next dev",
|
||||
"dev:http": "dotenv -e ../.env -- next dev",
|
||||
"dev": "dotenv -e ../.env -- next dev --experimental-https --experimental-https-key ./localhost+1-key.pem --experimental-https-cert ./localhost+1.pem",
|
||||
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
|
||||
"lint:fix": "dotenv -e ../.env -- next lint --fix",
|
||||
"clean": "rm -rf node_modules",
|
||||
@@ -125,6 +126,7 @@
|
||||
"posthog-js": "^1.176.0",
|
||||
"posthog-node": "^4.3.1",
|
||||
"prexit": "^2.2.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prisma": "^6.10.1",
|
||||
"protobufjs": "^7.4.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
@@ -137,7 +139,6 @@
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
"recharts": "^2.15.2",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -178,7 +179,6 @@
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
JobConfigState,
|
||||
type SafeWebhookActionConfig,
|
||||
type WebhookActionConfigWithSecrets,
|
||||
isWebhookAction,
|
||||
} from "@langfuse/shared";
|
||||
import { encrypt, decrypt } from "@langfuse/shared/encryption";
|
||||
import { generateWebhookSecret } from "@langfuse/shared/encryption";
|
||||
@@ -204,11 +205,10 @@ describe("automations trpc", () => {
|
||||
"x-api-key": encrypt("secret-api-key-123"),
|
||||
authorization: encrypt("Bearer secret-token-456"),
|
||||
},
|
||||
secretHeaderKeys: ["x-api-key", "authorization"],
|
||||
displayHeaders: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "secr***123",
|
||||
authorization: "Bear***456",
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-api-key": { secret: true, value: "secr***123" },
|
||||
authorization: { secret: true, value: "Bear***456" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
@@ -232,13 +232,14 @@ describe("automations trpc", () => {
|
||||
});
|
||||
|
||||
expect(response).toHaveLength(1);
|
||||
const automationConfig = response[0].action.config;
|
||||
const automationConfig = response[0].action
|
||||
.config as SafeWebhookActionConfig;
|
||||
|
||||
// Should have display values, not encrypted values
|
||||
expect(automationConfig.displayHeaders).toEqual({
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "secr***123",
|
||||
authorization: "Bear***456",
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-api-key": { secret: true, value: "secr***123" },
|
||||
authorization: { secret: true, value: "Bear***456" },
|
||||
});
|
||||
|
||||
// Should NOT have the raw headers with encrypted values
|
||||
@@ -321,7 +322,9 @@ describe("automations trpc", () => {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/webhook",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
displayHeaders: { "Content-Type": "application/json" },
|
||||
displayHeaders: {
|
||||
"Content-Type": { secret: false, value: "application/json" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
displaySecretKey,
|
||||
@@ -353,20 +356,22 @@ describe("automations trpc", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const actionConfig = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// check that the action does not have a secret key in the config
|
||||
expect(response.action.config).not.toHaveProperty("secretKey");
|
||||
expect(response.action.config).toHaveProperty("displaySecretKey");
|
||||
expect(response.action.config.url).toBe("https://example.com/webhook");
|
||||
expect(response.action.config).not.toHaveProperty("headers");
|
||||
expect(response.action.config.displayHeaders).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
expect(actionConfig).not.toHaveProperty("secretKey");
|
||||
expect(actionConfig).toHaveProperty("displaySecretKey");
|
||||
expect(actionConfig.url).toBe("https://example.com/webhook");
|
||||
expect(actionConfig).not.toHaveProperty("headers");
|
||||
expect(actionConfig.displayHeaders).toEqual({
|
||||
"Content-Type": { secret: false, value: "application/json" },
|
||||
});
|
||||
expect(response.action.config.apiVersion).toEqual({ prompt: "v1" });
|
||||
expect(response.action.config.type).toBe("WEBHOOK");
|
||||
expect(response.action.config.displaySecretKey).toBe(displaySecretKey);
|
||||
expect(response.action.config).not.toHaveProperty("headers");
|
||||
expect(response.action.config).not.toHaveProperty("decryptedHeaders");
|
||||
expect(response.action.config).not.toHaveProperty("requestHeaders");
|
||||
expect(actionConfig.apiVersion).toEqual({ prompt: "v1" });
|
||||
expect(actionConfig.type).toBe("WEBHOOK");
|
||||
expect(actionConfig.displaySecretKey).toBe(displaySecretKey);
|
||||
expect(actionConfig).not.toHaveProperty("headers");
|
||||
expect(actionConfig).not.toHaveProperty("decryptedHeaders");
|
||||
expect(actionConfig).not.toHaveProperty("requestHeaders");
|
||||
});
|
||||
|
||||
it("should not expose secret headers in single automation response", async () => {
|
||||
@@ -398,11 +403,10 @@ describe("automations trpc", () => {
|
||||
"x-custom": "public-value",
|
||||
"x-secret": encrypt("secret-value-789"),
|
||||
},
|
||||
secretHeaderKeys: ["x-secret"],
|
||||
displayHeaders: {
|
||||
"content-type": "application/json",
|
||||
"x-custom": "public-value",
|
||||
"x-secret": "secr***789",
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-custom": { secret: false, value: "public-value" },
|
||||
"x-secret": { secret: true, value: "secr***789" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
@@ -425,13 +429,13 @@ describe("automations trpc", () => {
|
||||
automationId: automation.id,
|
||||
});
|
||||
|
||||
const config = response.action.config;
|
||||
const config = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Should have display values
|
||||
expect(config.displayHeaders).toEqual({
|
||||
"content-type": "application/json",
|
||||
"x-custom": "public-value",
|
||||
"x-secret": "secr***789",
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-custom": { secret: false, value: "public-value" },
|
||||
"x-secret": { secret: true, value: "secr***789" },
|
||||
});
|
||||
|
||||
// Should NOT have raw encrypted headers
|
||||
@@ -488,7 +492,7 @@ describe("automations trpc", () => {
|
||||
automationId: automation.id,
|
||||
});
|
||||
|
||||
const config = response.action.config;
|
||||
const config = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Should have display values with all legacy header values returned
|
||||
// Legacy headers are converted to the new format with secret: false
|
||||
@@ -629,6 +633,7 @@ describe("automations trpc", () => {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/mixed-headers",
|
||||
});
|
||||
expect(isWebhookAction(createdAction as any)).toBe(true);
|
||||
|
||||
// Headers should be encrypted for secret ones, plain for others
|
||||
const config = createdAction?.config as WebhookActionConfigWithSecrets;
|
||||
@@ -795,6 +800,85 @@ describe("automations trpc", () => {
|
||||
}),
|
||||
).rejects.toThrow("Name is required");
|
||||
});
|
||||
|
||||
it("should create a new Slack automation", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create Slack integration first
|
||||
await prisma.slackIntegration.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
teamId: "T123456",
|
||||
teamName: "Test Team",
|
||||
botToken: encrypt("xoxb-test-token"),
|
||||
botUserId: "U123456",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await caller.automations.createAutomation({
|
||||
projectId: project.id,
|
||||
name: "New Slack Automation",
|
||||
eventSource: "prompt",
|
||||
eventAction: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.ACTIVE,
|
||||
actionType: "SLACK",
|
||||
actionConfig: {
|
||||
type: "SLACK",
|
||||
channelId: "C123456",
|
||||
channelName: "general",
|
||||
messageTemplate: JSON.stringify([
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "Custom template" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.trigger).toMatchObject({
|
||||
projectId: project.id,
|
||||
eventSource: "prompt",
|
||||
eventActions: ["created"],
|
||||
status: JobConfigState.ACTIVE,
|
||||
});
|
||||
|
||||
expect(response.action).toMatchObject({
|
||||
projectId: project.id,
|
||||
type: "SLACK",
|
||||
config: expect.objectContaining({
|
||||
type: "SLACK",
|
||||
channelId: "C123456",
|
||||
channelName: "general",
|
||||
}),
|
||||
});
|
||||
|
||||
// Ensure no bot token is exposed in response
|
||||
expect(JSON.stringify(response)).not.toContain("xoxb-");
|
||||
});
|
||||
|
||||
it("should fail to create Slack automation without integration", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
await expect(
|
||||
caller.automations.createAutomation({
|
||||
projectId: project.id,
|
||||
name: "Invalid Slack Automation",
|
||||
eventSource: "prompt",
|
||||
eventAction: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.ACTIVE,
|
||||
actionType: "SLACK",
|
||||
actionConfig: {
|
||||
type: "SLACK",
|
||||
channelId: "C123456",
|
||||
channelName: "general",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Slack integration not found. Please connect your Slack workspace first.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automations.updateAutomation", () => {
|
||||
@@ -922,9 +1006,9 @@ describe("automations trpc", () => {
|
||||
"x-case-key": { secret: false, value: "some-value" },
|
||||
},
|
||||
displayHeaders: {
|
||||
"content-type": "application/json",
|
||||
"x-old-header": "old-value",
|
||||
"x-case-key": "some-value",
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-old-header": { secret: false, value: "old-value" },
|
||||
"x-case-key": { secret: false, value: "some-value" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
@@ -963,19 +1047,20 @@ describe("automations trpc", () => {
|
||||
apiVersion: { prompt: "v1" },
|
||||
},
|
||||
});
|
||||
const actionConfig = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Verify the API response contains safe display values
|
||||
expect(response.action.config.displayHeaders).toMatchObject({
|
||||
expect(actionConfig.displayHeaders).toMatchObject({
|
||||
"content-type": { secret: false, value: "application/json" }, // header preserved
|
||||
"x-public": { secret: false, value: "new-public-value" }, // new public header
|
||||
"x-secret-key": { secret: true, value: "new-...-123" }, // new secret header
|
||||
"x-Case-KEY": { secret: false, value: "new-value" }, // matched existing key, but new value
|
||||
});
|
||||
|
||||
expect(response.action.config.displayHeaders).not.toHaveProperty(
|
||||
expect(actionConfig.displayHeaders).not.toHaveProperty(
|
||||
"x-old-header", // header deleted
|
||||
);
|
||||
expect(response.action.config.displayHeaders).not.toHaveProperty(
|
||||
expect(actionConfig.displayHeaders).not.toHaveProperty(
|
||||
"x-case-key", // new case replaced the old header name
|
||||
);
|
||||
|
||||
@@ -1083,9 +1168,10 @@ describe("automations trpc", () => {
|
||||
apiVersion: { prompt: "v1" },
|
||||
},
|
||||
});
|
||||
const actionConfig = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Verify the API response contains safe display values reflecting the switch
|
||||
expect(response.action.config.displayHeaders).toMatchObject({
|
||||
expect(actionConfig.displayHeaders).toMatchObject({
|
||||
"x-currently-public": { secret: true, value: "now-...alue" },
|
||||
"x-currently-secret": { secret: false, value: "now-public-value" },
|
||||
});
|
||||
@@ -1097,7 +1183,7 @@ describe("automations trpc", () => {
|
||||
where: { id: action.id },
|
||||
});
|
||||
|
||||
const config = updatedAction?.config as any;
|
||||
const config = updatedAction?.config as WebhookActionConfigWithSecrets;
|
||||
|
||||
// x-currently-public should now be encrypted (was plain, now secret)
|
||||
expect(config.requestHeaders["x-currently-public"].value).not.toBe(
|
||||
@@ -1283,14 +1369,13 @@ describe("automations trpc", () => {
|
||||
apiVersion: { prompt: "v1" },
|
||||
},
|
||||
});
|
||||
const actionConfig = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Verify the URL was updated
|
||||
expect(response.action.config.url).toBe(
|
||||
"https://example.com/new-webhook-url",
|
||||
);
|
||||
expect(actionConfig.url).toBe("https://example.com/new-webhook-url");
|
||||
|
||||
// Verify secret headers were preserved
|
||||
expect(response.action.config.displayHeaders).toMatchObject({
|
||||
expect(actionConfig.displayHeaders).toMatchObject({
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-api-key": { secret: true, value: "secr...-123" },
|
||||
authorization: { secret: true, value: "Bear...-456" },
|
||||
@@ -1388,8 +1473,10 @@ describe("automations trpc", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const actionConfig = response.action.config as SafeWebhookActionConfig;
|
||||
|
||||
// Verify the API response
|
||||
expect(response.action.config.displayHeaders).toMatchObject({
|
||||
expect(actionConfig.displayHeaders).toMatchObject({
|
||||
"content-type": { secret: false, value: "application/json" },
|
||||
"x-api-key": { secret: true, value: "new-...-key" },
|
||||
});
|
||||
@@ -1780,6 +1867,199 @@ describe("automations trpc", () => {
|
||||
|
||||
expect(response.count).toBe(3);
|
||||
});
|
||||
|
||||
it("should return 0 consecutive failures when lastFailingExecutionId is set", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create automation
|
||||
const trigger = await prisma.trigger.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
eventSource: "prompt",
|
||||
eventActions: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.INACTIVE, // Disabled due to failures
|
||||
},
|
||||
});
|
||||
|
||||
const { secretKey, displaySecretKey } = generateWebhookSecret();
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
type: "WEBHOOK",
|
||||
config: {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/webhook",
|
||||
requestHeaders: {
|
||||
"Content-Type": { secret: false, value: "application/json" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
displaySecretKey,
|
||||
lastFailingExecutionId: "some-failing-execution-id", // This simulates a webhook that was disabled
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const automation = await prisma.automation.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
name: "Test Automation",
|
||||
},
|
||||
});
|
||||
|
||||
// Create failed executions that occurred BEFORE the lastFailingExecutionId
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
id: "some-failing-execution-id",
|
||||
automationId: automation.id,
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
status: ActionExecutionStatus.ERROR,
|
||||
sourceId: v4(),
|
||||
input: { iteration: 0 },
|
||||
error: "Old failure",
|
||||
createdAt: new Date(Date.now() - 60000), // 1 minute ago
|
||||
},
|
||||
});
|
||||
|
||||
// Create more failed executions that occurred BEFORE the lastFailingExecutionId
|
||||
for (let i = 1; i < 5; i++) {
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
automationId: automation.id,
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
status: ActionExecutionStatus.ERROR,
|
||||
sourceId: v4(),
|
||||
input: { iteration: i },
|
||||
error: `Old failure ${i}`,
|
||||
createdAt: new Date(Date.now() - (60000 + i * 1000)), // Before the lastFailingExecutionId
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await caller.automations.getCountOfConsecutiveFailures({
|
||||
projectId: project.id,
|
||||
automationId: automation.id,
|
||||
});
|
||||
|
||||
// Should return 0 because all failures occurred before the lastFailingExecutionId
|
||||
expect(response.count).toBe(0);
|
||||
});
|
||||
|
||||
it("should count failures after lastFailingExecutionId correctly", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create automation
|
||||
const trigger = await prisma.trigger.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
eventSource: "prompt",
|
||||
eventActions: ["created"],
|
||||
filter: [],
|
||||
status: JobConfigState.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const { secretKey, displaySecretKey } = generateWebhookSecret();
|
||||
const lastFailingExecutionId = v4();
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
type: "WEBHOOK",
|
||||
config: {
|
||||
type: "WEBHOOK",
|
||||
url: "https://example.com/webhook",
|
||||
requestHeaders: {
|
||||
"Content-Type": { secret: false, value: "application/json" },
|
||||
},
|
||||
apiVersion: { prompt: "v1" },
|
||||
secretKey: encrypt(secretKey),
|
||||
displaySecretKey,
|
||||
lastFailingExecutionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const automation = await prisma.automation.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
name: "Test Automation",
|
||||
},
|
||||
});
|
||||
|
||||
// Create the lastFailingExecution
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
id: lastFailingExecutionId,
|
||||
automationId: automation.id,
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
status: ActionExecutionStatus.ERROR,
|
||||
sourceId: v4(),
|
||||
input: { iteration: 0 },
|
||||
error: "Last failing execution",
|
||||
createdAt: new Date(Date.now() - 60000), // 1 minute ago
|
||||
},
|
||||
});
|
||||
|
||||
// Create old failures that should be ignored
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
automationId: automation.id,
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
status: ActionExecutionStatus.ERROR,
|
||||
sourceId: v4(),
|
||||
input: { iteration: i },
|
||||
error: `Old failure ${i}`,
|
||||
createdAt: new Date(Date.now() - (120000 + i * 1000)), // Before the lastFailingExecutionId
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create new failures AFTER the lastFailingExecutionId
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await prisma.automationExecution.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
automationId: automation.id,
|
||||
projectId: project.id,
|
||||
triggerId: trigger.id,
|
||||
actionId: action.id,
|
||||
status: ActionExecutionStatus.ERROR,
|
||||
sourceId: v4(),
|
||||
input: { iteration: i },
|
||||
error: `New failure ${i}`,
|
||||
createdAt: new Date(Date.now() - (30000 - i * 1000)), // After the lastFailingExecutionId
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await caller.automations.getCountOfConsecutiveFailures({
|
||||
projectId: project.id,
|
||||
automationId: automation.id,
|
||||
});
|
||||
|
||||
// Should return 2 because only the 2 new failures after lastFailingExecutionId should be counted
|
||||
expect(response.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automations.regenerateWebhookSecret", () => {
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user