Compare commits

...
55 Commits
Author SHA1 Message Date
Max Deichmann bd0b204d18 chore: release v3.80.0 2025-07-09 19:30:09 +02:00
Max DeichmannandGitHub 94586e3f62 fix: fix webhook storage (#7783) 2025-07-09 17:20:49 +00:00
steffen911 63349976fa chore: rename migration folder 2025-07-09 18:02:21 +02:00
Max DeichmannandGitHub 8d092d0e18 chore: remove prompt logs (#7779)
* security: upgrade golang-migrate

* push

* push

* push
2025-07-09 15:45:43 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
c8c031e0bd feat(blob-integration): allow custom start date for blob storage exports (#7745)
* chore: formatting

* chore: add migration file and patches

* format

* chore: add test case

* chore: patch test case

* chore; frontend cleanup

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update .devcontainer.json

* chore: test

* patch azure

* chore: revert

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-09 15:44:22 +00:00
Steffen SchmitzandGitHub b616b0d260 fix(dashboard): allow score numeric value filter and metric (#7771)
* fix(dashboard): allow score numeric value filter and metric

* revert

* chore: drop debug log
2025-07-09 13:33:55 +00:00
Hassieb PakzadandGitHub 91d929646e chore(prompt-experiments): upgrade langfuse-langchain (#7770) 2025-07-09 13:18:14 +00:00
NimarandGitHub bfdbd93997 feat(playground): allow arbitrary values for placeholders in playground (#7768)
* allow arbitrary values for placeholders in playground

* update playground

* fix lint

* re-add removed functions

* stringify if arbitrary

* safety first!
2025-07-09 12:14:57 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2916b4228d chore: improve retry script for ingestion events (#7769)
* chore: improve retry script for ingestion events

* chore: move file

* Update worker/src/scripts/replayIngestionEvents/README.md

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-09 11:30:31 +00:00
e5a04751e1 fix: Ensure json output for single value csv import (#7763)
* Ensure CSV parsing always returns JSON object for single columns

Co-authored-by: marc <marc@langfuse.com>

* Update web/src/features/datasets/lib/csvHelpers.ts

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-09 10:57:11 +00:00
bb554b38af chore: remove webhook button from single prompt page (#7762)
* Changes from background composer bc-61bc6129-a444-40fd-9e03-d397fc1c9ee1

* fix lint

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-09 10:57:05 +00:00
marliessophieandGitHub cfc8161473 chore(experimentService): extend logging (#7759)
* chore(experimentService): extend logging

* chore: eslint

* chore: revert
2025-07-09 08:49:41 +00:00
Steffen SchmitzandGitHub aecd8eff2d chore: add backoff retries to clickhouse writer (#7761) 2025-07-09 08:39:54 +00:00
Nimar 74c59f58c2 chore: release v3.79.1 2025-07-09 09:55:32 +02:00
NimarandGitHub e073ec9a21 fix(traces): jumptoplayground possible for langgraph traces with tools (#7744)
* fix jumptoplayground for langgraph

* update type
2025-07-09 07:38:31 +00:00
NimarandGitHub 5920f3a6c7 chore(dx): add prettier to CI (#7735)
* add prettier to CI

* escape

* use an array

* make CI simple again

* debug

* debug

* debug2

* debug again
2025-07-08 08:54:55 +00:00
c2a0b381d4 feat: Add gen_ai.conversation.id support (#7738)
Changes from background composer bc-11040caa-e818-42ef-8bb2-4b004ae862a7

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-08 06:45:45 +00:00
7598c95fc3 fix(prompts): prompts with many labels look good (#7731)
* Add TruncatedLabels component for flexible label display with hover

Co-authored-by: nimar <nimar@langfuse.com>

* Refactor: Improve code formatting and consistency across components

Co-authored-by: nimar <nimar@langfuse.com>

* add many labelled prompt

* add many labelled prompt

* remove superfluous code

* do 8 labels here

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-07 15:16:08 +00:00
Steffen SchmitzandGitHub 35f6f5f0fc feat(dashboard): allow breakdown by month (#7725)
* feat(dashboard): allow breakdown by month

* chore: add aliases to the queries

* chore: add test case and formatting

* chore: adjust test behaviour
2025-07-07 15:07:39 +00:00
da23a5ad0b chore(ui-markdown-viewer): stop interpreting $currency symbol as latex math block marker (#7723)
Remove remark-math plugin from MarkdownViewer

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-07 14:38:08 +00:00
Max DeichmannandGitHub 5adac14049 chore: print response body webhook errors (#7728)
* security: upgrade golang-migrate

* push

* push
2025-07-07 13:36:45 +00:00
Nimar 234f131493 chore: release v3.79.0 2025-07-07 15:24:20 +02:00
Nimar c87e3f88fc chore: release v3.78.3 2025-07-07 15:17:43 +02:00
b0aeb8c454 fix(prompts): handle placeholder messages in diff view (#7726)
* Improve prompt diff display with formatted content for placeholders

Co-authored-by: nimar <nimar@langfuse.com>

* tmp

* Update PromptVersionDiffDialog.tsx

* fix diff

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-07 13:15:14 +00:00
NimarandGitHub a71ac105b9 chore(dx): fix prettier version in ee (#7722)
* chore(dx): fix prettier in ee

* fix tailwind

* update formatting

* cache formattgin
2025-07-07 12:09:29 +00:00
Steffen SchmitzandGitHub 225d64d0f8 build: add registry prefix to docker-compose files (#7719) 2025-07-07 11:48:33 +00:00
Steffen SchmitzandGitHub 327018eb09 build: remove swap settings from pipeline config (#7717) 2025-07-07 11:29:05 +00:00
fe8ba75ff3 chore(dx): prettier for all (#7692)
* add prettier command

* add pre-commit hook

* sync prettier config with .vscode config

* add line

* fix husky

* update for less changes

* upgrade to prettier 3.4 for bug fixes

* ignore no-undef in TS (also worker!)

* changes to prettier

* adhere to style

* make prettier 3.4 default for bug fix

* add prettier ignore

* reset to standard

* update to 3.6.2

---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-07 11:00:45 +00:00
Jannik MaierhöferandGitHub 74e8c0fe58 chore(ui): change link on traces and observations table to data model docs page (#7713) 2025-07-07 09:15:09 +00:00
marliessophieandGitHub 4f57af8ea1 refactor(datasets-compare): implement context for managing selected resource metrics (#7660)
* refactor(datasets-compare): implement context for managing selected resource metrics

* chore: push

* chore: add dialog header wrapper
2025-07-07 09:12:17 +00:00
Max DeichmannandGitHub 4132335c96 security: upgrade golang-migrate (#7712)
* security: upgrade golang-migrate

* push
2025-07-07 08:52:53 +00:00
Steffen SchmitzandGitHub 67dae75e3a chore: add timestamp column to AMTs for backwards compat (#7710)
* chore: add timestamp column to AMTs for backwards compat

* chore; expand allowed claude functions
2025-07-07 08:12:30 +00:00
ed0a07a958 fix(cloud): update CSP to allow for file upload in support chat (#7703)
Update CSP to allow connections to AWS S3 domains

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-06 12:45:09 +02:00
Steffen SchmitzandGitHub 0a63cc61fc chore: fix timestamp ordering for amt table (#7700)
* chore: fix timestamp ordering for amt table

* chore: update comment
2025-07-05 13:50:53 +00:00
Max DeichmannandGitHub c385121406 chore: improve auto selection of automations page (#7698)
* chore: add webhook throughput alert

* chore: add webhook throughput alert

* chore: add webhook throughput alert
2025-07-05 08:28:45 +00:00
Max DeichmannandGitHub 7d7932e024 fix: do not store output of webhook automations (#7697)
* push

* fixes
2025-07-05 08:07:46 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
b26362cbca feat: webhook for prompt version changes (#7012)
* push

* fix

* fix

* fix

* fix

* fix

* push

* fix

* push

* push

* push

* push

* fix

* fix

* push

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fixes

* Update .cursor/rules/global.mdc

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update web/src/features/rbac/constants/projectAccessRights.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* push

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* push

* change event prcessing

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* Update worker/src/__tests__/webhooks.test.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* push

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fix tests

* Update web/src/__tests__/async/prompts.v2.servertest.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* data model

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-05 07:02:30 +00:00
NimarandGitHub 9b8095f8f5 chore(dx): unit tests run against their own db (#7671)
* chore(dx): unit tests run against their own db

* fix clickhouse

* clean up a bit

* close

* update only use different postgres

* update with command

* remove CH
2025-07-04 14:50:27 +00:00
Hassieb PakzadandGitHub 8b76617019 fix(dataset-run-items-api-spec): correct response type (#7690) 2025-07-04 15:20:54 +02:00
93e50f4720 feat(prompts): Add prompt filtering by config (#7688)
* Add config column to prompts table for JSON filtering

Co-authored-by: marc <marc@langfuse.com>

* remove plan

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-04 13:09:07 +00:00
Hassieb PakzadandGitHub c1388c8699 chore: upgrade langfuse-langchain (#7687) 2025-07-04 12:37:00 +00:00
Steffen SchmitzandGitHub 43fc305d5b fix: create virtual timestamp column in AMT query to support all filters (#7676)
* fix: create virtual timestamp column in AMT query to support all filters

* chore: add test logging

* chore: skip database prune for async tests

* chore: cleanup

* chore: drop console logs
2025-07-04 12:06:43 +00:00
marliessophieandGitHub ebd4cf3e8a chore(sentry): restrict loggerLink to development environment only for less noise in sentry (#7684) 2025-07-04 12:00:23 +00:00
NimarandGitHub 5dfc821575 fix: run prettier on my changes (#7685)
format all
2025-07-04 11:52:37 +00:00
marliessophieandGitHub 3965a5174f chore(sentry): only report server side errors (5xx) to sentry; skip reporting client side errors (#7682) 2025-07-04 11:09:46 +00:00
Max DeichmannandGitHub df237a11d5 chore: refactor prompts test (#7680)
* push

* fixes

* fixes

* fixes
2025-07-04 09:55:27 +00:00
marliessophieandGitHub 373cefdcb5 chore(sentry): do not report expected API key errors to sentry (#7678) 2025-07-04 08:57:37 +00:00
Steffen SchmitzandGitHub b92a8c6d81 fix(dashboard): adjust filters on view change and expose bad request errors (#7675)
* fix(dashboard): adjust filters on view change and expose bad request errors

* chore: skip error typing
2025-07-04 08:52:27 +00:00
Steffen SchmitzandGitHub 68422d404c chore(dashboard): remove breakdowns by 1:N dimensional relations (#7674) 2025-07-04 07:28:42 +00:00
Steffen SchmitzandGitHub d42c4e5e2f perf: start to experimentally shift reads to new aggregatingmergetrees (#7631)
* perf: start to experimentally shift reads to new aggregatingmergetrees

* chore: linting

* chore: more linting

* chore: create checklist for trace table view migrations

* feat: shift by id queries onto new AMTs

* chore: cover traces for public api

* chore: add notes for maxMap

* chore: incorporate feedback into query declarations

* chore: cleanup queries

* chore: adjust reads to new layout

* chore: update ingestion pipeline to make use of nullable fields

* chore: add sampling

* chore: update sampling decision attribute

* chore: fix limit for getTracesByIds

* chore: spacing

* chore: linting
2025-07-03 17:30:10 +00:00
0d2586e4f2 feat(ui): Add trace download button, exports trace and observation metadata as json (#7668)
* Add download trace as JSON button to TracePage

Co-authored-by: marc <marc@langfuse.com>

* Add JSON download functionality for trace details

Co-authored-by: marc <marc@langfuse.com>

* Checkpoint before follow-up message

* Refactor trace download button placement and add conditional rendering

Co-authored-by: marc <marc@langfuse.com>

* Refactor trace download to export trace and observations data

Co-authored-by: marc <marc@langfuse.com>

* fix

* push

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-03 17:28:00 +00:00
Hassieb PakzadandGitHub ff40e79143 feat(llm-connections): add gemini 2.5 flash lite and gemini 2.5 prices (#7666)
* feat(llm-connections): add gemini 2.5 flash lite and gemini 2.5 prices

* push

* add reasoning
2025-07-03 16:25:11 +00:00
marliessophieandGitHub bd04f6b186 fix(nav): implement responsive navigation with DropdownMenu for mobile (#7665)
* feat(nav): implement responsive navigation with DropdownMenu for mobile

* chore: push
2025-07-03 16:06:44 +00:00
Max DeichmannandGitHub d107f98654 perf: increase ingestion queue retry attempts (#7649)
* push

* push
2025-07-03 12:55:37 +00:00
Hassieb PakzadandGitHub de9fa8199a feat(llm-connections): add gemini 2.5 pro and flash GA (#7659) 2025-07-03 12:33:32 +00:00
234 changed files with 12517 additions and 2629 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(pnpm run test:*)"
],
"deny": []
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ RUN corepack enable && \
# ---------- golang-migrate ----------------------------------------------------
# CLI used for database migrations during development.
ENV MIGRATE_VERSION=4.18.2
ENV MIGRATE_VERSION=4.18.3
RUN wget -qO- "https://github.com/golang-migrate/migrate/releases/download/v${MIGRATE_VERSION}/migrate.linux-amd64.tar.gz" \
| tar -xz -C /usr/local/bin && \
chmod +x /usr/local/bin/migrate
+20
View File
@@ -0,0 +1,20 @@
---
description:
globs:
alwaysApply: true
---
## Project setup
- This project is a Turborepo monorepo
- We build two containers, web (./web) and worker (./worker)
- We have shared code between these two in the shared package (./packages/shared). For that package, we have different entry points [package.json](mdc:packages/shared/package.json).
## Domain layer
The most important domain objects are in [observations.ts](mdc:packages/shared/src/domain/observations.ts), [traces.ts](mdc:packages/shared/src/domain/traces.ts), [scores.ts](mdc:packages/shared/src/domain/scores.ts).
## Database schema
We use Postgres and Clickhouse.
- The postgres schema is in [schema.prisma](mdc:packages/shared/prisma/schema.prisma)
- The clickhouse schema is in [0001_traces.up.sql](mdc:packages/shared/clickhouse/migrations/clustered/0001_traces.up.sql), [0002_observations.up.sql](mdc:packages/shared/clickhouse/migrations/clustered/0002_observations.up.sql), [0003_scores.up.sql](mdc:packages/shared/clickhouse/migrations/clustered/0003_scores.up.sql)
+3 -2
View File
@@ -1,6 +1,6 @@
---
description: How to create new public api routes for Langfuse
globs:
globs:
alwaysApply: false
---
@@ -13,4 +13,5 @@ alwaysApply: false
- Add end-to-end test, similar to [datasets-api.servertest.ts](mdc:web/src/__tests__/async/datasets-api.servertest.ts)
- Add fern configuration in /fern, learn more about structure here: https://buildwithfern.com/learn/api-definition/fern/overview
- Prompt user to regenerate the OpenAPI spec via the fern CLI
- Pagination starts at 1, query typing defined in publicApiPaginationZod, return meta in paginationMetaResponseZod
- Pagination starts at 1, query typing defined in publicApiPaginationZod, return meta in paginationMetaResponseZod
- For tests, please look at [this directory](mdc:web/src/__tests__/async/) for examples
+9
View File
@@ -0,0 +1,9 @@
---
description:
globs:
alwaysApply: true
---
# Writing tests
- When writing tests, focus on decoupling each `it` or `test` block to ensure that they can run independently and concurrently. Tests must never depend on the action or outcome of previous or subsequent tests.
- When writing tests, especially in the __tests__/async directory, ensure that you avoid `pruneDatabase` calls.
+12
View File
@@ -0,0 +1,12 @@
# Test Environment Configuration
# Copy this file to .env.test for test database isolation
# Only overrides specific test variables - other values inherited from .env
# PostgreSQL - Test Database
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/langfuse_test"
# ClickHouse - Use Default Database for now, nothing set
# Redis - Test Database (database 1 for isolation)
REDIS_CONNECTION_STRING="redis://:myredissecret@127.0.0.1:6379/1"
+1 -1
View File
@@ -28,7 +28,7 @@ Fixes # (issue)
<!-- Remove bullet points below that don't apply to you -->
- I haven't read the [contributing guide](https://github.com/langfuse/langfuse/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project (`npm run prettier`)
- My code doesn't follow the style guidelines of this project (`pnpm run format`)
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings (`npm run lint`)
+49 -17
View File
@@ -52,6 +52,49 @@ jobs:
- name: lint web
run: pnpm run lint
prettier-check:
runs-on: ubuntu-latest
needs:
- pre-job
if: needs.pre-job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9.5.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: install dependencies
run: |
pnpm i
- name: Load default env
run: |
cp .env.dev.example .env
- name: Check formatting on changed files
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA=${{ github.event.pull_request.base.sha }}
else
BASE_SHA=$(git merge-base origin/main HEAD)
fi
echo "Checking files changed from $BASE_SHA to HEAD"
# Get changed files
CHANGED_FILES=$(git diff --name-only $BASE_SHA HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.css' | tr '\n' ' ')
if [ -n "$CHANGED_FILES" ] && [ "$CHANGED_FILES" != " " ]; then
echo "Files to check: $CHANGED_FILES"
pnpm prettier --check --experimental-cli $CHANGED_FILES
else
echo "No JS/TS/CSS files changed - skipping prettier check"
fi
test-docker-build:
timeout-minutes: 20
runs-on: ubuntu-latest
@@ -100,14 +143,10 @@ jobs:
node-version: [20]
postgres-version: [12, 15]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 10
- uses: actions/checkout@v4
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -178,14 +217,10 @@ jobs:
postgres-version: [12, 15]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 10
- uses: actions/checkout@v4
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- uses: pnpm/action-setup@v3
@@ -257,10 +292,6 @@ jobs:
postgres-version: [12, 15]
deploy-mode: ["", "-azure", "-redis-cluster"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
with:
swap-size-gb: 10
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
@@ -282,7 +313,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -343,7 +374,7 @@ jobs:
cp .env.dev.example web/.env
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Run + migrate
@@ -397,7 +428,7 @@ jobs:
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.linux-amd64.tar.gz | tar xvz
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
@@ -442,6 +473,7 @@ jobs:
needs:
[
lint,
prettier-check,
tests-web-sync,
tests-worker,
e2e-tests,
+1
View File
@@ -42,6 +42,7 @@ yarn-error.log*
!.env.dev-azure.example
!.env.dev-redis-cluster.example
!.env.prod.example
!.env.test.example
# vercel
.vercel
+4 -3
View File
@@ -11,12 +11,13 @@ if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
exit 0 # Commit will proceed
# Commit approved, check formatting. On files changed, block commit
pnpm run format:check
else
echo "Commit to $protected_branch branch has been canceled."
exit 1 # Commit will be blocked
fi
fi
# If not the protected branch, proceed with the commit
exit 0
# If not the protected branch, check formatting (on changed files, block)
pnpm run format:check
+2
View File
@@ -0,0 +1,2 @@
# Generated code
packages/shared/prisma/generated
+2
View File
@@ -8,6 +8,8 @@ Langfuse is an **open source LLM engineering** platform for developing, monitori
## Tests
- Codex cannot run the test suite because it depends on Docker-based infrastructure that is unavailable in this environment.
- When writing tests, focus on decoupling each `it` or `test` block to ensure that they can run independently and concurrently. Tests must never depend on the action or outcome of previous or subsequent tests.
- When writing tests, especially in the __tests__/async directory, ensure that you avoid `pruneDatabase` calls.
## Cursor Rules
- Additional folder-specific rules live in `.cursor/rules/`.
+6
View File
@@ -94,6 +94,7 @@ pnpm run test --filter=worker -- $TEST_FILE_NAME -t "$TEST_NAME"
### Utilities
```bash
pnpm run format # Format code across entire project
pnpm run nuke # Remove all node_modules, build files, wipe database, docker containers. **USE WITH CAUTION**
```
@@ -152,6 +153,8 @@ pnpm run nuke # Remove all node_modules, build files, wipe database
- Jest for API tests, Playwright for E2E tests
- For backend/API changes, tests must pass before pushes
- Add tests for new API endpoints and features
- When writing tests, focus on decoupling each `it` or `test` block to ensure that they can run independently and concurrently. Tests must never depend on the action or outcome of previous or subsequent tests.
- When writing tests, especially in the __tests__/async directory, ensure that you avoid `pruneDatabase` calls.
### Code Conventions
- **Pages Router** (not App Router)
@@ -186,3 +189,6 @@ To get a project, use the `get_project` capability with the full project name as
## TypeScript Best Practices
- In TypeScript, if possible, don't use the `any` type
## General Coding Guidelines
- For easier code reviews, prefer not to move functions etc around within a file unless necessary or instructed to do so
+35 -4
View File
@@ -124,7 +124,14 @@ Requirements
cd langfuse
```
3. Create an env file
3. Install dependencies and set up pre-commit hooks
```bash
pnpm install
pnpm run prepare # Sets up Husky pre-commit hooks for code formatting
```
4. Create an env file
```bash
cp .env.dev.example .env
@@ -133,7 +140,7 @@ Requirements
4. Run the entire infrastructure in dev mode. **Note**: if you have an existing database, this command wipes it.
```bash
pnpm run dx # first run only (resets db, node_modules, ...)
pnpm run dx # first run only (resets db, docker containers, etc...)
pnpm run dev # any subsequent runs
```
@@ -149,8 +156,8 @@ Requirements
- Username: `demo@langfuse.com`
- Password: `password`
To get comprehensive example data, you can use the `seed` command:
```sh
pnpm run db:seed:examples
```
@@ -209,31 +216,55 @@ On the main branch, we adhere to the best practices of [conventional commits](ht
All tests run in the CI and must pass before merging.
All tests run against a running langfuse instance and **write/delete real data from the database**.
### Test Database Setup
Per default, the tests use the local development database. Therefore, wiping your data in the process.
For proper test isolation, create a `.env.test` file in the root directory:
```bash
cp .env.test.example .env.test
```
Then, a different PostgreSQL and Redis are used for the tests.
The `.env.test` file only overrides the set values and falls back on `.env` for all undefined values.
- **PostgreSQL**: Uses separate `langfuse_test` database for isolation
- **ClickHouse**: Uses shared `default` database for now
- **Redis**: Uses database 1 instead of 0 for isolation (Redis data is not cleaned between tests)
Tests automatically create the PostgreSQL test database if it doesn't exist and clean up data between runs.
### Tests in the `web` package (public API)
We're using Jest with in the `web` package. Therefore, if you want to provide an argument to the test runner, do it directly without an intermittent ` -- `.
We're using Jest with in the `web` package. Therefore, if you want to provide an argument to the test runner, do it directly without an intermittent `--`.
There are three types of unit tests:
- `test-sync`
- `test-async`
- `test-client`
To run a specific test, for example the test: `"should handle special characters in prompt names"` in `prompts.v2.servertest.ts`, run:
```sh
cd web # or with --filter=web
pnpm test-sync --testPathPattern="prompts\.v2\.servertest" --testNamePattern="should handle special characters in prompt names"
```
To run all tests:
```sh
pnpm run test
```
Run interactively in watch mode (not recommended!)
```sh
pnpm run test:watch
```
### Tests in the `worker` package
For the `worker` package, we're using `vitest` to run unit tests.
```sh
+4 -4
View File
@@ -78,7 +78,7 @@ services:
retries: 3
clickhouse:
image: clickhouse/clickhouse-server
image: docker.io/clickhouse/clickhouse-server
user: "101:101"
environment:
CLICKHOUSE_DB: default
@@ -98,7 +98,7 @@ services:
start_period: 1s
minio:
image: minio/minio
image: docker.io/minio/minio
entrypoint: sh
# create the 'langfuse' bucket before starting the service
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
@@ -118,7 +118,7 @@ services:
start_period: 1s
redis:
image: redis:7
image: docker.io/redis:7
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
@@ -131,7 +131,7 @@ services:
retries: 10
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+3 -3
View File
@@ -1,6 +1,6 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.3
image: docker.io/clickhouse/clickhouse-server:24.3
user: "101:101"
environment:
CLICKHOUSE_DB: default
@@ -24,7 +24,7 @@ services:
- langfuse_azurite_data:/data
redis:
image: redis:7.2.4
image: docker.io/redis:7.2.4
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
@@ -32,7 +32,7 @@ services:
- 6379:6379
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+3 -3
View File
@@ -1,6 +1,6 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.3
image: docker.io/clickhouse/clickhouse-server:24.3
user: "101:101"
environment:
CLICKHOUSE_DB: default
@@ -16,7 +16,7 @@ services:
- postgres
minio:
image: minio/minio
image: docker.io/minio/minio
entrypoint: sh
# create the 'langfuse' bucket before starting the service
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
@@ -36,7 +36,7 @@ services:
start_period: 1s
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+4 -4
View File
@@ -1,6 +1,6 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.3
image: docker.io/clickhouse/clickhouse-server:24.3
user: "101:101"
environment:
CLICKHOUSE_DB: default
@@ -16,7 +16,7 @@ services:
- postgres
minio:
image: minio/minio
image: docker.io/minio/minio
entrypoint: sh
# create the 'langfuse' bucket before starting the service
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
@@ -36,7 +36,7 @@ services:
start_period: 1s
redis:
image: redis:7.2.4
image: docker.io/redis:7.2.4
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
@@ -44,7 +44,7 @@ services:
- 127.0.0.1:6379:6379
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+6 -6
View File
@@ -5,7 +5,7 @@
# External connections from other machines will not be able to reach these services directly.
services:
langfuse-worker:
image: langfuse/langfuse-worker:3
image: docker.io/langfuse/langfuse-worker:3
restart: always
depends_on: &langfuse-depends-on
postgres:
@@ -64,7 +64,7 @@ services:
REDIS_TLS_KEY: ${REDIS_TLS_KEY:-/certs/redis.key}
langfuse-web:
image: langfuse/langfuse:3
image: docker.io/langfuse/langfuse:3
restart: always
depends_on: *langfuse-depends-on
ports:
@@ -84,7 +84,7 @@ services:
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
clickhouse:
image: clickhouse/clickhouse-server
image: docker.io/clickhouse/clickhouse-server
restart: always
user: "101:101"
environment:
@@ -105,7 +105,7 @@ services:
start_period: 1s
minio:
image: minio/minio
image: docker.io/minio/minio
restart: always
entrypoint: sh
# create the 'langfuse' bucket before starting the service
@@ -126,7 +126,7 @@ services:
start_period: 1s
redis:
image: redis:7
image: docker.io/redis:7
restart: always
# CHANGEME: row below to secure redis password
command: >
@@ -140,7 +140,7 @@ services:
retries: 10
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
image: docker.io/postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
+1 -1
View File
@@ -41,7 +41,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.3.3",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.4.5"
@@ -27,7 +27,7 @@ service:
limit:
type: optional<integer>
docs: limit of items per page
response: PaginatedDatasetRunItems
response: PaginatedDatasetRunItems
types:
CreateDatasetRunItemRequest:
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.78.2",
"version": "3.80.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -17,9 +17,9 @@
"db:seed": "turbo run db:seed",
"db:seed:examples": "turbo run db:seed:examples",
"nuke": "bash ./scripts/nuke.sh",
"dx": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset -f && SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset:test && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx-f": "pnpm i && pnpm run infra:dev:prune && pnpm run infra:dev:up --pull always && pnpm --filter=shared run db:reset:test && pnpm --filter=shared run db:reset -f && SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"dx:skip-infra": "pnpm i && pnpm --filter=shared run db:reset:test && pnpm --filter=shared run db:reset && pnpm --filter=shared run ch:reset && pnpm --filter=shared run db:seed:examples && pnpm run dev",
"build": "turbo run build",
"start": "turbo run start",
"dev": "turbo run dev",
@@ -27,6 +27,8 @@
"dev:web": "turbo run dev --filter=web",
"dev:web-turbo": "turbo run dev --filter=web -- --turbo",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\" --experimental-cli",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,css}\" --experimental-cli",
"test": "turbo run test",
"release": "dotenv -e ../.env -- release-it",
"prepare": "husky"
@@ -36,7 +38,7 @@
"braces": "3.0.3",
"dotenv-cli": "^7.4.2",
"husky": "^9.0.11",
"prettier": "^3.3.3",
"prettier": "^3.6.2",
"release-it": "^19.0.3",
"turbo": "^2.5.4"
},
+8
View File
@@ -35,5 +35,13 @@ module.exports = {
{
files: ["*.js?(x)", "*.ts?(x)"],
},
{
files: ["*.ts", "*.mts", "*.cts", "*.tsx"],
// no-undef doesn't make sense in TS, see:
// https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
rules: {
"no-undef": "off",
},
},
],
};
+3 -2
View File
@@ -38,6 +38,7 @@
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
"db:reset": "dotenv -e ../../.env npx -- prisma migrate reset",
"db:reset:test": "if [ -f ../../.env.test ]; then dotenv -e ../../.env.test -e ../../.env -- npx prisma migrate reset --force; fi",
"db:deploy": "dotenv -e ../../.env npx -- prisma migrate deploy",
"db:seed": "dotenv -e ../../.env -- npx prisma db seed",
"db:generate": "dotenv -e ../../.env -- npx prisma generate",
@@ -84,7 +85,7 @@
"jsonpath-plus": "10.3.0",
"kysely": "^0.27.4",
"langchain": "^0.3.28",
"langfuse-langchain": "3.38.1",
"langfuse-langchain": "3.38.4",
"lodash": "^4.17.21",
"lossless-json": "^4.1.1",
"next-auth": "^4.24.11",
@@ -111,7 +112,7 @@
"eslint-plugin-prettier": "^5.1.3",
"kysely-codegen": "^0.16.8",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"prettier": "^3.6.2",
"prisma": "^6.10.1",
"prisma-erd-generator": "^1.11.2",
"prisma-kysely": "^1.8.0",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
-- CreateEnum
CREATE TYPE "ActionType" AS ENUM ('WEBHOOK');
-- CreateEnum
CREATE TYPE "ActionExecutionStatus" AS ENUM ('COMPLETED', 'ERROR', 'PENDING', 'CANCELLED');
-- CreateTable
CREATE TABLE "actions" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"project_id" TEXT NOT NULL,
"type" "ActionType" NOT NULL,
"config" JSONB NOT NULL,
CONSTRAINT "actions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "triggers" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"project_id" TEXT NOT NULL,
"eventSource" TEXT NOT NULL,
"eventActions" TEXT[],
"filter" JSONB,
"status" "JobConfigState" NOT NULL DEFAULT 'ACTIVE',
CONSTRAINT "triggers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "automations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"trigger_id" TEXT NOT NULL,
"action_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"project_id" TEXT NOT NULL,
CONSTRAINT "automations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "automation_executions" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"source_id" TEXT NOT NULL,
"automation_id" TEXT NOT NULL,
"trigger_id" TEXT NOT NULL,
"action_id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"status" "ActionExecutionStatus" NOT NULL DEFAULT 'PENDING',
"input" JSONB NOT NULL,
"output" JSONB,
"started_at" TIMESTAMP(3),
"finished_at" TIMESTAMP(3),
"error" TEXT,
CONSTRAINT "automation_executions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "actions_project_id_idx" ON "actions"("project_id");
-- CreateIndex
CREATE INDEX "triggers_project_id_idx" ON "triggers"("project_id");
-- CreateIndex
CREATE INDEX "automations_project_id_action_id_trigger_id_idx" ON "automations"("project_id", "action_id", "trigger_id");
-- CreateIndex
CREATE INDEX "automations_project_id_name_idx" ON "automations"("project_id", "name");
-- CreateIndex
CREATE INDEX "automation_executions_trigger_id_idx" ON "automation_executions"("trigger_id");
-- CreateIndex
CREATE INDEX "automation_executions_action_id_idx" ON "automation_executions"("action_id");
-- CreateIndex
CREATE INDEX "automation_executions_project_id_idx" ON "automation_executions"("project_id");
-- AddForeignKey
ALTER TABLE "actions" ADD CONSTRAINT "actions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "triggers" ADD CONSTRAINT "triggers_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automations" ADD CONSTRAINT "automations_trigger_id_fkey" FOREIGN KEY ("trigger_id") REFERENCES "triggers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automations" ADD CONSTRAINT "automations_action_id_fkey" FOREIGN KEY ("action_id") REFERENCES "actions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automations" ADD CONSTRAINT "automations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_automation_id_fkey" FOREIGN KEY ("automation_id") REFERENCES "automations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_trigger_id_fkey" FOREIGN KEY ("trigger_id") REFERENCES "triggers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_action_id_fkey" FOREIGN KEY ("action_id") REFERENCES "actions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "automation_executions" ADD CONSTRAINT "automation_executions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "BlobStorageExportMode" AS ENUM ('FULL_HISTORY', 'FROM_TODAY', 'FROM_CUSTOM_DATE');
-- AlterTable
ALTER TABLE "blob_storage_integrations" ADD COLUMN "export_mode" "BlobStorageExportMode" NOT NULL DEFAULT 'FULL_HISTORY',
ADD COLUMN "export_start_date" TIMESTAMP(3);
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"
+120
View File
@@ -157,6 +157,10 @@ model Project {
Dashboard Dashboard[]
DashboardWidget DashboardWidget[]
TableViewPreset TableViewPreset[]
actions Action[]
triggers Trigger[]
automationExecutions AutomationExecution[]
Automation Automation[]
DefaultLlmModel DefaultLlmModel[]
@@index([orgId])
@@ -963,6 +967,8 @@ model BlobStorageIntegration {
enabled Boolean
exportFrequency String @map("export_frequency")
fileType BlobStorageIntegrationFileType @default(CSV) @map("file_type")
exportMode BlobStorageExportMode @default(FULL_HISTORY) @map("export_mode")
exportStartDate DateTime? @map("export_start_date")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@ -986,6 +992,14 @@ enum BlobStorageIntegrationType {
@@map("BlobStorageIntegrationType")
}
enum BlobStorageExportMode {
FULL_HISTORY
FROM_TODAY
FROM_CUSTOM_DATE
@@map("BlobStorageExportMode")
}
model BatchExport {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
@@ -1214,3 +1228,109 @@ model TableViewPreset {
@@unique([projectId, tableName, name])
@@map("table_view_presets")
}
model Action {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
type ActionType
// Configuration specific to each action type
config Json // Structured JSON for different action types
// For WEBHOOK: { version: "1.0", url: "...", method: "POST", headers: {...}, secretId: "..." }
// For ANNOTATION_QUEUE: { version: "1.0", queueId: "..." }
automations Automation[]
automationExecutions AutomationExecution[]
@@index([projectId])
@@map("actions")
}
model Trigger {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// When should this trigger fire
eventSource String // trace, prompt, etc.
eventActions String[] // created, updated, deleted
filter Json? // Filter conditions (format: { field: "name", operator: "equals", value: "my_trace" })
// Additional attributes
status JobConfigState @default(ACTIVE) @map("status")
// Link to executions
automationExecutions AutomationExecution[]
automations Automation[]
@@index([projectId])
@@map("triggers")
}
model Automation {
id String @id @default(cuid())
name String @map("name")
trigger Trigger @relation(fields: [triggerId], references: [id], onDelete: Cascade)
triggerId String @map("trigger_id")
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
actionId String @map("action_id")
createdAt DateTime @default(now()) @map("created_at")
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
AutomationExecution AutomationExecution[]
@@index([projectId, actionId, triggerId])
@@index([projectId, name])
@@map("automations")
}
enum ActionType {
WEBHOOK
// More action types can be added as needed
}
enum ActionExecutionStatus {
COMPLETED
ERROR
PENDING
CANCELLED
}
model AutomationExecution {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
sourceId String @map("source_id")
automationId String @map("automation_id")
automation Automation @relation(fields: [automationId], references: [id], onDelete: Cascade)
triggerId String @map("trigger_id")
trigger Trigger @relation(fields: [triggerId], references: [id], onDelete: Cascade)
actionId String @map("action_id")
action Action @relation(fields: [actionId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
status ActionExecutionStatus @default(PENDING) @map("status")
input Json @map("input")
output Json? @map("output")
startedAt DateTime? @map("started_at")
finishedAt DateTime? @map("finished_at")
error String? @map("error")
@@index([triggerId])
@@index([actionId])
@@index([projectId])
@@map("automation_executions")
}
@@ -4,15 +4,16 @@ import { hash } from "bcryptjs";
import { v4 } from "uuid";
import { encrypt } from "../../src/encryption";
import {
type JobConfiguration,
JobExecutionStatus,
PrismaClient,
type Project,
ScoreDataType,
type JobConfiguration,
JobExecutionStatus,
PrismaClient,
type Project,
ScoreDataType,
} from "../../src/index";
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
import { redis } from "../../src/server/redis/redis";
import {EVAL_TRACE_COUNT,
import {
EVAL_TRACE_COUNT,
FAILED_EVAL_TRACE_INTERVAL,
SEED_CHAT_ML_PROMPTS,
SEED_DATASETS,
@@ -271,6 +271,68 @@ export const SEED_TEXT_PROMPTS = [
labels: ["production", "latest"],
tags: ["tag1", "tag2"],
},
{
id: `prompt-with-many-labels`,
createdBy: "user-1",
prompt:
"This is a comprehensive prompt for testing multiple label scenarios. It demonstrates how prompts can be tagged with numerous labels for organization, categorization, and filtering purposes. Use this prompt to understand how label management works at scale. Variables: {{input}}",
name: "prompt-with-many-labels",
version: 1,
labels: [
"production",
"latest",
"v1",
"v2",
"stable",
"beta",
"alpha",
"test",
"development",
"staging",
"experimental",
"feature",
"bugfix",
"hotfix",
"critical",
"high-priority",
"medium-priority",
"low-priority",
"urgent",
"customer-facing",
"internal",
"public",
"private",
"confidential",
"ai",
"nlp",
"chatbot",
"assistant",
"automation",
"ml",
"data",
"analytics",
"monitoring",
"logging",
"debug",
"performance",
"security",
"compliance",
"audit",
"review",
"approved",
"rejected",
"pending",
"archived",
"deprecated",
"legacy",
"migration",
"upgrade",
"downgrade",
"template",
"example",
],
tags: [],
},
];
export const SEED_CHAT_ML_PROMPTS = [
+2 -1
View File
@@ -88,7 +88,8 @@ declare const globalThis: {
kyselyPrismaGlobal: { $kysely: Kysely<DB> } | undefined;
} & typeof global;
if (process.env.NODE_ENV === "development") { // eslint-disable-line turbo/no-undeclared-env-vars
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NODE_ENV === "development") {
globalThis.prismaGlobal ??= createPrismaInstance(); // regular instantiation
globalThis.kyselyPrismaGlobal ??= globalThis.prismaGlobal.$extends(
kyselyExtension({
+83
View File
@@ -0,0 +1,83 @@
import { Action, Trigger } from "@prisma/client";
import { FilterState } from "../types";
import { z } from "zod/v4";
export enum TriggerEventSource {
// eslint-disable-next-line no-unused-vars
Prompt = "prompt",
}
export const EventActionSchema = z.enum(["created", "updated", "deleted"]);
export type TriggerEventAction = z.infer<typeof EventActionSchema>;
export const TriggerEventSourceSchema = z.enum([TriggerEventSource.Prompt]);
export type TriggerDomain = Omit<
Trigger,
"filter" | "eventSource" | "eventActions"
> & {
filter: FilterState;
eventSource: TriggerEventSource;
eventActions: TriggerEventAction[];
};
export type AutomationDomain = {
id: string;
name: string;
trigger: TriggerDomain;
action: ActionDomain;
};
export type ActionDomain = Omit<Action, "config"> & {
config: SafeWebhookActionConfig;
};
export type ActionDomainWithSecrets = Omit<Action, "config"> & {
config: WebhookActionConfigWithSecrets;
};
export const ActionTypeSchema = z.enum(["WEBHOOK"]);
export const AvailableWebhookApiSchema = z.record(
z.enum(["prompt"]),
z.enum(["v1"]),
);
export const WebhookActionConfigSchema = z.object({
type: z.literal("WEBHOOK"),
url: z.url(),
headers: z.record(z.string(), z.string()),
apiVersion: AvailableWebhookApiSchema,
secretKey: z.string(),
displaySecretKey: z.string(),
});
export const SafeWebhookActionConfigSchema = WebhookActionConfigSchema.omit({
secretKey: true,
});
export type SafeWebhookActionConfig = z.infer<
typeof SafeWebhookActionConfigSchema
>;
export const WebhookActionCreateSchema = WebhookActionConfigSchema.omit({
secretKey: true,
displaySecretKey: true,
});
export const ActionConfigSchema = z.discriminatedUnion("type", [
WebhookActionConfigSchema,
]);
export const ActionCreateSchema = z.discriminatedUnion("type", [
WebhookActionCreateSchema,
]);
export type ActionTypes = z.infer<typeof ActionTypeSchema>;
export type ActionConfig = z.infer<typeof ActionConfigSchema>;
export type ActionCreate = z.infer<typeof ActionCreateSchema>;
export type WebhookActionConfigWithSecrets = z.infer<
typeof WebhookActionConfigSchema
>;
+3
View File
@@ -2,3 +2,6 @@ export * from "./observations";
export * from "./traces";
export * from "./scores";
export * from "./table-view-presets";
export * from "./automations";
export * from "./webhooks";
export * from "./prompts";
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod/v4";
import { jsonSchemaNullable } from "../utils/zod";
export const PromptDomainSchema = z.object({
id: z.string(),
name: z.string(),
version: z.number(),
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string(),
isActive: z.boolean().nullable(),
type: z.string().default("text"),
tags: z.array(z.string()).default([]),
labels: z.array(z.string()).default([]),
prompt: jsonSchemaNullable,
config: jsonSchemaNullable,
projectId: z.string(),
commitMessage: z.string().nullable(),
});
export type PromptDomain = z.infer<typeof PromptDomainSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod/v4";
import { jsonSchema } from "../utils/zod";
import { EventActionSchema } from "./automations";
export const WebhookDefaultHeaders = {
"Content-Type": "application/json",
"User-Agent": "Langfuse/1.0",
};
export const WebhookOutboundBaseSchema = z.object({
id: z.string(),
timestamp: z.coerce.date(),
type: z.literal("prompt-version"),
apiVersion: z.literal("v1"),
action: EventActionSchema,
});
export const PromptWebhookOutboundSchema = z
.object({
prompt: z.object({
id: z.string(),
name: z.string(),
version: z.number(),
projectId: z.string(),
labels: z.array(z.string()),
prompt: jsonSchema.nullable(),
type: z.string(),
config: z.record(z.string(), z.any()),
commitMessage: z.string().nullable(),
tags: z.array(z.string()),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}),
})
.and(WebhookOutboundBaseSchema);
export type PromptWebhookOutput = z.infer<typeof PromptWebhookOutboundSchema>;
@@ -0,0 +1,60 @@
import crypto from "crypto";
import { env } from "../env";
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
const IV_LENGTH: number = 12; // For AES-GCM, this is always 12
// Alternatively: openssl rand -hex 32
export function keyGen() {
return crypto.randomBytes(32).toString("hex");
}
/**
* Encrypts the given plain text using AES-256-GCM algorithm.
*
* @param {string} plainText - The text to encrypt.
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
*/
export function encrypt(plainText: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(ENCRYPTION_KEY, "hex"),
iv,
);
let encrypted = cipher.update(plainText, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Return iv, encrypted data, and authTag as hex, combined in one line
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
}
export function decrypt(text: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const [ivHex, encryptedHex, authTagHex] = text.split(":");
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error("Invalid or corrupted cipher format");
}
const iv = Buffer.from(ivHex, "hex");
const encryptedText = Buffer.from(encryptedHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(ENCRYPTION_KEY, "hex"),
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, undefined, "utf8");
decrypted += decipher.final("utf8");
return decrypted.toString();
}
+2 -60
View File
@@ -1,60 +1,2 @@
import crypto from "crypto";
import { env } from "../env";
const ENCRYPTION_KEY: string | undefined = env.ENCRYPTION_KEY; // Must be 256 bits (32 bytes, 64 hex characters)
const IV_LENGTH: number = 12; // For AES-GCM, this is always 12
// Alternatively: openssl rand -hex 32
export function keyGen() {
return crypto.randomBytes(32).toString("hex");
}
/**
* Encrypts the given plain text using AES-256-GCM algorithm.
*
* @param {string} plainText - The text to encrypt.
* @returns {string} The encrypted data in hex format, including IV and authentication tag.
*/
export function encrypt(plainText: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const iv = crypto.randomBytes(IV_LENGTH); // Directly use Buffer returned by randomBytes
const cipher = crypto.createCipheriv(
"aes-256-gcm",
Buffer.from(ENCRYPTION_KEY, "hex"),
iv,
);
let encrypted = cipher.update(plainText, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Return iv, encrypted data, and authTag as hex, combined in one line
return iv.toString("hex") + ":" + encrypted + ":" + authTag.toString("hex");
}
export function decrypt(text: string): string {
if (!ENCRYPTION_KEY) {
throw new Error("Missing environment variable: `ENCRYPTION_KEY`");
}
const [ivHex, encryptedHex, authTagHex] = text.split(":");
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error("Invalid or corrupted cipher format");
}
const iv = Buffer.from(ivHex, "hex");
const encryptedText = Buffer.from(encryptedHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
Buffer.from(ENCRYPTION_KEY, "hex"),
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, undefined, "utf8");
decrypted += decipher.final("utf8");
return decrypted.toString();
}
export * from "./encryption";
export * from "./signature";
@@ -0,0 +1,41 @@
import crypto from "crypto";
// Generate a webhook secret key
export function generateWebhookSecret(): {
secretKey: string;
displaySecretKey: string;
} {
// Generate 32 random bytes and encode as hex (64 characters)
const rawSecret = crypto.randomBytes(32).toString("hex");
const secretKey = `lf-whsec_${rawSecret}`;
return { secretKey, displaySecretKey: getDisplaySecretKey(secretKey) };
}
// Create display version of webhook secret
export function getDisplaySecretKey(secretKey: string): string {
if (!secretKey || secretKey.length < 12) {
// whsec_ + at least 4 chars
return "****";
}
return `lf-whsec_...${secretKey.slice(-4)}`;
}
// Generate HMAC-SHA256 signature for webhook payload
export function generateWebhookSignature(
payload: string,
timestamp: number,
secret: string,
) {
const signedPayload = `${timestamp}.${payload}`;
return crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
}
export function createSignatureHeader(payload: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const signature = generateWebhookSignature(payload, timestamp, secret);
return `t=${timestamp},v1=${signature}`;
}
+24
View File
@@ -107,6 +107,30 @@ const EnvSchema = z.object({
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_EXPERIMENT_SAMPLING_RATE: z.coerce
.number()
.min(0)
.max(1)
.default(0.1),
LANGFUSE_EXPERIMENT_WHITELISTED_PROJECT_IDS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT: z
.enum(["true", "false"])
.default("false"),
});
export const env: z.infer<typeof EnvSchema> =
+1 -1
View File
@@ -7,7 +7,7 @@ export class BaseError extends Error {
name: string,
httpCode: number,
description: string,
isOperational: boolean
isOperational: boolean,
) {
super(description);
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
+8 -1
View File
@@ -17,6 +17,7 @@ export * from "./utils/prompts";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
export * from "./domain/webhooks";
// llm api
export * from "./server/llm/types";
@@ -46,7 +47,13 @@ export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
export {
compileChatMessages,
compileChatMessagesWithIds,
isPlaceholder,
type MessagePlaceholderValues,
type PromptMessage as ServerPromptMessage,
} from "./server/llm/compileChatMessages";
// export db types only
export * from "@prisma/client";
@@ -12,9 +12,8 @@ interface CustomSSOUser extends Record<string, any> {
verified: boolean;
}
export function CustomSSOProvider<P extends CustomSSOUser>(
options: OAuthUserConfig<P>
options: OAuthUserConfig<P>,
): OAuthConfig<P> {
return {
id: "custom",
@@ -0,0 +1,247 @@
import { Job, Processor } from "bullmq";
import { backOff } from "exponential-backoff";
import {
ActionExecutionStatus,
JobConfigState,
} from "../../../prisma/generated/types";
import {
PromptWebhookOutboundSchema,
WebhookDefaultHeaders,
} from "../../domain";
import { prisma } from "../../db";
import { TQueueJobTypes, QueueName, WebhookInput } from "../queues";
import {
getActionByIdWithSecrets,
getAutomationById,
getConsecutiveAutomationFailures,
} from "../repositories";
import { logger } from "..";
import { createSignatureHeader } from "../../encryption/signature";
import { decrypt } from "../../encryption";
import { InternalServerError, LangfuseNotFoundError } from "../../errors";
export const webhookProcessor: Processor = async (
job: Job<TQueueJobTypes[QueueName.WebhookQueue]>,
) => {
try {
return await executeWebhook(job.data.payload);
} catch (error) {
logger.error("Error executing WebhookJob", error);
throw error;
}
};
// TODO: Webhook outgoing API versioning
export const executeWebhook = async (input: WebhookInput) => {
const executionStart = new Date();
const { projectId, automationId, executionId } = input;
let httpStatus: number | undefined;
let responseBody: string | undefined;
try {
logger.debug(`Executing webhook for automation ${automationId}`);
const automation = await getAutomationById({
projectId,
automationId,
});
if (!automation) {
throw new LangfuseNotFoundError(`Automation ${automationId} not found`);
}
const actionConfig = await getActionByIdWithSecrets({
projectId,
actionId: automation.action.id,
});
if (!actionConfig) {
throw new Error("Action config not found");
}
if (actionConfig.config.type !== "WEBHOOK") {
throw new InternalServerError("Action config is not a webhook");
}
// TypeScript now knows actionConfig.config is WebhookActionConfig
const webhookConfig = actionConfig.config;
const validatedPayload = PromptWebhookOutboundSchema.safeParse({
id: input.executionId,
timestamp: new Date(),
type: input.payload.type,
apiVersion: "v1",
action: input.payload.action,
prompt: input.payload.prompt,
});
if (!validatedPayload.success) {
throw new InternalServerError(
`Invalid webhook payload: ${validatedPayload.error.message}`,
);
}
// Prepare webhook payload with prompt always last
const { prompt, ...otherFields } = validatedPayload.data;
const webhookPayload = JSON.stringify({
...otherFields,
prompt,
});
// Prepare headers with signature if secret exists
const requestHeaders: Record<string, string> = {
...WebhookDefaultHeaders,
...webhookConfig.headers,
};
if (!webhookConfig.secretKey) {
logger.warn(
`Webhook config for action ${automation.action.id} has no secret key, failing webhook execution`,
);
throw new InternalServerError(
"Webhook config has no secret key, failing webhook execution",
);
}
if (webhookConfig.secretKey) {
try {
const decryptedSecret = decrypt(webhookConfig.secretKey);
const signature = createSignatureHeader(
webhookPayload,
decryptedSecret,
);
requestHeaders["x-langfuse-signature"] = signature;
} catch (error) {
logger.error(
"Failed to decrypt webhook secret or generate signature",
error,
);
throw new InternalServerError("Failed to generate webhook signature");
}
}
await backOff(
async () => {
logger.debug(
`Sending webhook to ${webhookConfig.url} with payload ${JSON.stringify(
webhookPayload,
)} and headers ${JSON.stringify(requestHeaders)}`,
);
const res = await fetch(webhookConfig.url, {
method: "POST",
body: webhookPayload,
headers: requestHeaders,
});
httpStatus = res.status;
responseBody = await res.text();
if (res.status !== 200) {
logger.warn(
`Webhook does not return 200: failed with status ${res.status} for url ${webhookConfig.url} and project ${projectId}. Body: ${responseBody}`,
);
throw new Error(
`Webhook does not return 200: failed with status ${res.status} for url ${webhookConfig.url} and project ${projectId}`,
);
}
},
{
numOfAttempts: 4, // no retries for webhook calls via BullMQ
},
);
// Update action execution status on success
await prisma.automationExecution.update({
where: {
projectId,
triggerId: automation.trigger.id,
actionId: automation.action.id,
id: executionId,
},
data: {
status: ActionExecutionStatus.COMPLETED,
startedAt: executionStart,
finishedAt: new Date(),
},
});
logger.debug(
`Webhook executed successfully for action ${automation.action.id}`,
);
} catch (error) {
logger.error("Error executing webhook", error);
const automation = await getAutomationById({
projectId,
automationId,
});
if (!automation) {
throw new LangfuseNotFoundError(`Automation ${automationId} not found`);
}
const shouldRetryJob =
error instanceof LangfuseNotFoundError ||
error instanceof InternalServerError;
if (shouldRetryJob) {
logger.warn(
`Retrying bullmq for webhook job for action ${automation.action.id}`,
);
throw error;
}
// Update action execution status and check if we should disable trigger
await prisma.$transaction(async (tx) => {
// Update execution status
await tx.automationExecution.update({
where: {
id: executionId,
projectId,
triggerId: automation.trigger.id,
actionId: automation.action.id,
},
data: {
status: ActionExecutionStatus.ERROR,
startedAt: executionStart,
finishedAt: new Date(),
error: error instanceof Error ? error.message : "Unknown error",
output: httpStatus
? {
httpStatus,
responseBody: responseBody?.substring(0, 1000),
}
: undefined,
},
});
// Check consecutive failures from execution history
const consecutiveFailures = await getConsecutiveAutomationFailures({
automationId,
projectId,
});
logger.info(
`Consecutive failures: ${consecutiveFailures} for trigger ${automation.trigger.id} in project ${projectId}`,
);
// Check if trigger should be disabled (this is the 5th failure, looking for 4 in the past.)
if (consecutiveFailures >= 4) {
await tx.trigger.update({
where: { id: automation.trigger.id, projectId },
data: { status: JobConfigState.INACTIVE },
});
logger.warn(
`Automation ${automation.trigger.id} disabled after ${consecutiveFailures} consecutive failures in project ${projectId}`,
);
}
});
logger.debug(
`Webhook failed for action ${automation.action.id} in project ${projectId}`,
);
}
};
@@ -0,0 +1,98 @@
import { instrumentAsync } from "../instrumentation";
import * as opentelemetry from "@opentelemetry/api";
import { env } from "../../env";
import { logger } from "../logger";
const executionWrapper = async <T, Y>(
input: T,
fn: (input: T) => Promise<Y>, // eslint-disable-line no-unused-vars
span?: opentelemetry.Span,
attributePrefix?: string,
): Promise<[Y, number]> => {
const startTime = Date.now();
const res = await fn(input);
const duration = Date.now() - startTime;
span?.setAttribute(
`langfuse.experiment.amts.${attributePrefix}-duration`,
duration,
);
return [res, duration];
};
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
}): Promise<Y> => {
return instrumentAsync(
{
name: `experiment-${args.operationName}`,
spanKind: opentelemetry.SpanKind.CLIENT,
},
async (currentSpan) => {
const { input, existingExecution, newExecution } = args;
if (
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES !==
"true"
) {
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "disabled");
return existingExecution(input);
}
// If not whitelisted, apply sampling logic
if (
!env.LANGFUSE_EXPERIMENT_WHITELISTED_PROJECT_IDS.includes(
args.projectId,
) &&
Math.random() > env.LANGFUSE_EXPERIMENT_SAMPLING_RATE
) {
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "sampled-out");
return existingExecution(input);
}
currentSpan.setAttribute(`langfuse.experiment.amts.run`, "true");
try {
const [[existingResult, existingDuration], [newResult, newDuration]] =
await Promise.all([
executionWrapper(input, existingExecution, currentSpan, "existing"),
executionWrapper(input, newExecution, currentSpan, "new"),
]);
// Positive duration difference means new is faster
const durationDifference = existingDuration - newDuration;
currentSpan?.setAttribute(
"langfuse.experiment.amts.execution-time-difference",
durationDifference,
);
if (
env.LANGFUSE_EXPERIMENT_ADD_QUERY_RESULT_TO_SPAN_PROJECT_IDS.some(
(p) => p === args.projectId,
)
) {
currentSpan?.setAttribute(
"langfuse.experiment.amts.existing-result",
JSON.stringify(existingResult),
);
currentSpan?.setAttribute(
"langfuse.experiment.amts.new-result",
JSON.stringify(newResult),
);
}
return env.LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT === "true"
? newResult
: existingResult;
} catch (e) {
logger.error(
"Failed to run experiment wrapper. Retrying existing query",
e,
);
return existingExecution(input);
}
},
);
};
+6
View File
@@ -3,6 +3,7 @@ export * from "./services/email/organizationInvitation/sendMembershipInvitationE
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/PromptService";
export * from "./services/PromptService/types";
export * from "./services/traces-ui-table-service";
export * from "./services/InMemoryFilterService";
export * from "./auth/apiKeys";
@@ -27,6 +28,7 @@ export * from "./redis/traceUpsert";
export * from "./redis/createEvalQueue";
export * from "./redis/cloudUsageMeteringQueue";
export * from "./redis/getQueue";
export * from "./redis/webhookQueue";
export * from "./redis/traceDelete";
export * from "./redis/projectDelete";
export * from "./redis/scoreDelete";
@@ -44,6 +46,7 @@ export * from "./redis/coreDataS3ExportQueue";
export * from "./redis/meteringDataPostgresExportQueue";
export * from "./redis/experimentCreateQueue";
export * from "./redis/dlqRetryQueue";
export * from "./redis/entityChangeQueue";
export * from "./auth/types";
export * from "./queues";
export * from "./orderByToPrisma";
@@ -59,9 +62,12 @@ export * from "./services/datasets-ui-table-service";
export * from "./services/DashboardService";
export * from "./services/TableViewService";
export * from "./services/DefaultEvaluationModelService";
export * from "./clickhouse/measureAndReturn";
export * from "./data-deletion/ingestionFileDeletion";
export * from "./s3";
export * from "./automations/webhooks";
// test utils
export * from "./test-utils";
@@ -45,7 +45,8 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
return s3StorageServiceClient;
};
export type TokenCountDelegate = (p: { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
export type TokenCountDelegate = (p: {
model: Model;
text: unknown;
}) => number | undefined;
@@ -1,21 +1,26 @@
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
import { type ChatMessage, type PlaceholderMessage, ChatMessageType, type PromptChatMessageSchema, type ChatMessageWithId, type ChatMessageWithIdNoPlaceholders, ChatMessageSchema } from "./types";
import {
type ChatMessage,
type PlaceholderMessage,
ChatMessageType,
type PromptChatMessageSchema,
type ChatMessageWithId,
type ChatMessageWithIdNoPlaceholders,
} from "./types";
export type MessagePlaceholderValues = Record<string, ChatMessage[]>;
export type MessagePlaceholderValues = Record<string, unknown[]>;
export type PromptMessage = z.infer<typeof PromptChatMessageSchema>;
export function isPlaceholder(message: PromptMessage): message is PlaceholderMessage {
export function isPlaceholder(
message: PromptMessage,
): message is PlaceholderMessage {
return "type" in message && message.type === ChatMessageType.Placeholder;
}
function validateMessage(message: unknown): message is ChatMessage {
return ChatMessageSchema.safeParse(message).success;
}
function replaceTextVariables(
content: string,
textVariables: Record<string, string>
textVariables: Record<string, string>,
): string {
let result = content;
for (const [varName, varValue] of Object.entries(textVariables)) {
@@ -28,35 +33,44 @@ function replaceTextVariables(
function expandPlaceholder(
placeholder: PlaceholderMessage,
placeholderValues: MessagePlaceholderValues
placeholderValues: MessagePlaceholderValues,
): ChatMessage[] {
const replacementMessages = placeholderValues[placeholder.name];
if (!replacementMessages) {
throw new Error(`Missing value for message placeholder: ${placeholder.name}`);
throw new Error(
`Missing value for message placeholder: ${placeholder.name}`,
);
}
if (!Array.isArray(replacementMessages)) {
throw new Error(`Placeholder value for '${placeholder.name}' must be an array of messages`);
throw new Error(
`Placeholder value for '${placeholder.name}' must be an array of messages`,
);
}
for (const replacementMsg of replacementMessages) {
if (!validateMessage(replacementMsg)) {
throw new Error(`Invalid message format in placeholder '${placeholder.name}': messages must have 'role' and 'content' properties`);
// Allow arbitrary objects - just pass them through as ChatMessage
// Users might want to use ChatML with placeholders for any message structure
return replacementMessages.map((replacementMsg) => {
if (typeof replacementMsg === "object" && replacementMsg !== null) {
return replacementMsg as ChatMessage;
}
}
return replacementMessages;
throw new Error(
`Invalid message in placeholder '${placeholder.name}': expected object but got ${typeof replacementMsg}`,
);
});
}
export function compileChatMessages(
messages: PromptMessage[],
placeholderValues: MessagePlaceholderValues,
textVariables?: Record<string, string>
textVariables?: Record<string, string>,
): ChatMessage[] {
const expandedMessages = messages.flatMap((message) =>
isPlaceholder(message)
? expandPlaceholder(message, placeholderValues)
: [message as ChatMessage]
: [message as ChatMessage],
);
// substitute text variables
@@ -65,27 +79,27 @@ export function compileChatMessages(
}
return expandedMessages.map((message) => {
if (!message.content) {
if (!message.content || typeof message.content !== "string") {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
content: replaceTextVariables(message.content, textVariables),
};
});
}
export function compileChatMessagesWithIds(
messages: ChatMessageWithId[],
placeholderValues: Record<string, ChatMessage[]>,
textVariables?: Record<string, string>
placeholderValues: MessagePlaceholderValues,
textVariables?: Record<string, string>,
): ChatMessageWithIdNoPlaceholders[] {
// TODO: check, is it even important to retain the IDs?
const expandedMessages = messages.flatMap((message) => {
if (isPlaceholder(message)) {
const expandedMsgs = expandPlaceholder(message, placeholderValues);
return expandedMsgs.map(msg => ({ ...msg, id: uuidv4() }));
return expandedMsgs.map((msg) => ({ ...msg, id: uuidv4() }));
} else {
// Preserve message IDs for already non-placeholder messages
return [message as ChatMessageWithIdNoPlaceholders];
@@ -98,19 +112,22 @@ export function compileChatMessagesWithIds(
}
return expandedMessages.map((message) => {
if (!message.content) {
if (!message.content || typeof message.content !== "string") {
return message;
}
return {
...message,
content: replaceTextVariables(message.content, textVariables)
content: replaceTextVariables(message.content, textVariables),
};
});
}
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PlaceholderMessage => "type" in msg && msg.type === ChatMessageType.Placeholder)
.map(msg => msg.name);
.filter(
(msg): msg is PlaceholderMessage =>
"type" in msg && msg.type === ChatMessageType.Placeholder,
)
.map((msg) => msg.name);
}
@@ -63,7 +63,8 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
};
export async function fetchLLMCompletion(
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: true;
},
): Promise<{
@@ -72,7 +73,8 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: false;
},
): Promise<{
@@ -81,7 +83,8 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
streaming: false;
structuredOutputSchema: ZodSchema;
},
@@ -91,7 +94,8 @@ export async function fetchLLMCompletion(
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
params: LLMCompletionParams & {
tools: LLMToolDefinition[];
streaming: false;
},
@@ -154,28 +158,47 @@ export async function fetchLLMCompletion(
finalCallbacks = finalCallbacks.length > 0 ? finalCallbacks : undefined;
// Helper function to safely stringify content
const safeStringify = (content: any): string => {
try {
return JSON.stringify(content);
} catch {
return "[Unserializable content]";
}
};
let finalMessages: BaseMessage[];
// VertexAI requires at least 1 user message
if (modelParams.adapter === LLMAdapter.VertexAI && messages.length === 1) {
finalMessages = [new HumanMessage(messages[0].content)];
const safeContent =
typeof messages[0].content === "string"
? messages[0].content
: JSON.stringify(messages[0].content);
finalMessages = [new HumanMessage(safeContent)];
} else {
finalMessages = messages.map((message) => {
// For arbitrary content types, convert to string safely
const safeContent =
typeof message.content === "string"
? message.content
: safeStringify(message.content);
if (message.role === ChatMessageRole.User)
return new HumanMessage(message.content);
return new HumanMessage(safeContent);
if (
message.role === ChatMessageRole.System ||
message.role === ChatMessageRole.Developer
)
return new SystemMessage(message.content);
return new SystemMessage(safeContent);
if (message.type === ChatMessageType.ToolResult)
return new ToolMessage({
content: message.content,
content: safeContent,
tool_call_id: message.toolCallId,
});
return new AIMessage({
content: message.content,
content: safeContent,
tool_calls:
message.type === ChatMessageType.AssistantToolCall
? (message.toolCalls as any)
@@ -325,7 +348,7 @@ export async function fetchLLMCompletion(
/*
Workaround OpenAI reasoning models:
This is a temporary workaround to avoid sending unsupported parameters to OpenAI's O1 models.
O1 models do not support:
- system messages
+18 -5
View File
@@ -176,7 +176,12 @@ export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
export const PlaceholderMessageSchema = z.object({
type: z.literal(ChatMessageType.Placeholder),
name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "Placeholder name must start with a letter and contain only alphanumeric characters and underscores"),
name: z
.string()
.regex(
/^[a-zA-Z][a-zA-Z0-9_]*$/,
"Placeholder name must start with a letter and contain only alphanumeric characters and underscores",
),
});
export type PlaceholderMessage = z.infer<typeof PlaceholderMessageSchema>;
@@ -191,7 +196,7 @@ export const ChatMessageSchema = z.union([
z
.object({
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
content: z.string(),
content: z.union([z.string(), z.array(z.any()), z.any()]), // Support arbitrary content types for message placeholders
})
.transform((msg) => {
return {
@@ -202,8 +207,10 @@ export const ChatMessageSchema = z.union([
]);
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export type ChatMessageWithId = (ChatMessage & { id: string }) | (PlaceholderMessage & { id: string });
export type ChatMessageWithIdNoPlaceholders = (ChatMessage & { id: string });
export type ChatMessageWithId =
| (ChatMessage & { id: string })
| (PlaceholderMessage & { id: string });
export type ChatMessageWithIdNoPlaceholders = ChatMessage & { id: string };
export const PromptChatMessageSchema = z.union([
z.object({
@@ -335,9 +342,12 @@ export const anthropicModels = [
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
export const vertexAIModels = [
"gemini-2.0-flash",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash-preview-05-20",
"gemini-2.0-flash",
"gemini-2.0-pro-exp-02-05",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-preview-02-05",
@@ -349,6 +359,9 @@ export const vertexAIModels = [
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys. Make sure it supports top_p, max_tokens and temperature.
export const googleAIStudioModels = [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash-preview-05-20",
"gemini-2.0-flash",
@@ -432,19 +432,23 @@ export class FilterList {
this.filters.push(...filter);
}
find(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
find(predicate: (filter: Filter) => boolean) {
return this.filters.find(predicate);
}
filter(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
filter(predicate: (filter: Filter) => boolean) {
return new FilterList(this.filters.filter(predicate));
}
some(predicate: (filter: Filter) => boolean) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
some(predicate: (filter: Filter) => boolean) {
return this.filters.some(predicate);
}
forEach(callback: (filter: Filter) => void) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
forEach(callback: (filter: Filter) => void) {
this.filters.forEach(callback);
}
+47
View File
@@ -6,6 +6,8 @@ import {
BatchActionType,
} from "../features/batchAction/types";
import { BatchTableNames } from "../interfaces/tableNames";
import { EventActionSchema } from "../domain";
import { PromptDomainSchema } from "../domain/prompts";
export const IngestionEvent = z.object({
data: z.object({
@@ -129,6 +131,33 @@ export const DeadLetterRetryQueueEventSchema = z.object({
timestamp: z.date(),
});
export const WebhookOutboundEnvelopeSchema = z.object({
prompt: PromptDomainSchema,
action: EventActionSchema,
type: z.literal("prompt-version"),
});
export const WebhookInputSchema = z.object({
projectId: z.string(),
automationId: z.string(),
executionId: z.string(),
payload: WebhookOutboundEnvelopeSchema,
});
export const EntityChangeEventSchema = z.discriminatedUnion("entityType", [
z.object({
entityType: z.literal("prompt-version"),
projectId: z.string(),
promptId: z.string(),
action: EventActionSchema,
prompt: PromptDomainSchema,
}),
// 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<
typeof CreateEvalQueueEventSchema
>;
@@ -161,6 +190,8 @@ export type DeadLetterRetryQueueEventType = z.infer<
typeof DeadLetterRetryQueueEventSchema
>;
export type WebhookQueueEventType = z.infer<typeof WebhookInputSchema>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
TraceDelete = "trace-delete",
@@ -184,6 +215,8 @@ export enum QueueName {
CreateEvalQueue = "create-eval-queue",
ScoreDelete = "score-delete",
DeadLetterRetryQueue = "dead-letter-retry-queue",
WebhookQueue = "webhook-queue",
EntityChangeQueue = "entity-change-queue",
}
export enum QueueJobs {
@@ -209,6 +242,8 @@ export enum QueueJobs {
CreateEvalJob = "create-eval-job",
ScoreDelete = "score-delete",
DeadLetterRetryJob = "dead-letter-retry-job",
WebhookJob = "webhook-job",
EntityChangeJob = "entity-change-job",
}
export type TQueueJobTypes = {
@@ -308,4 +343,16 @@ export type TQueueJobTypes = {
payload: DeadLetterRetryQueueEventType;
name: QueueJobs.DeadLetterRetryJob;
};
[QueueName.WebhookQueue]: {
timestamp: Date;
id: string;
payload: WebhookInput;
name: QueueJobs.WebhookJob;
};
[QueueName.EntityChangeQueue]: {
timestamp: Date;
id: string;
payload: EntityChangeEventType;
name: QueueJobs.EntityChangeJob;
};
};
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class BatchExportQueue {
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class BlobStorageIntegrationProcessingQueue {
@@ -18,8 +22,10 @@ export class BlobStorageIntegrationProcessingQueue {
BlobStorageIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.BlobStorageIntegrationProcessingQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.BlobStorageIntegrationProcessingQueue),
connection: newRedis,
prefix: getQueuePrefix(
QueueName.BlobStorageIntegrationProcessingQueue,
),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -38,4 +44,4 @@ export class BlobStorageIntegrationProcessingQueue {
return BlobStorageIntegrationProcessingQueue.instance;
}
}
}
@@ -1,7 +1,11 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class CloudUsageMeteringQueue {
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
import { env } from "../../env";
@@ -23,8 +27,8 @@ export class CoreDataS3ExportQueue {
CoreDataS3ExportQueue.instance = newRedis
? new Queue(QueueName.CoreDataS3ExportQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.CoreDataS3ExportQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.CoreDataS3ExportQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class CreateEvalQueue {
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DataRetentionProcessingQueue {
@@ -18,8 +22,8 @@ export class DataRetentionProcessingQueue {
DataRetentionProcessingQueue.instance = newRedis
? new Queue(QueueName.DataRetentionProcessingQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionProcessingQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionProcessingQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10000,
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DataRetentionQueue {
@@ -18,8 +22,8 @@ export class DataRetentionQueue {
DataRetentionQueue.instance = newRedis
? new Queue(QueueName.DataRetentionQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.DataRetentionQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DatasetRunItemUpsertQueue {
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DeadLetterRetryQueue {
@@ -18,8 +22,8 @@ export class DeadLetterRetryQueue {
DeadLetterRetryQueue.instance = newRedis
? new Queue(QueueName.DeadLetterRetryQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.DeadLetterRetryQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.DeadLetterRetryQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -0,0 +1,50 @@
import { Queue } from "bullmq";
import { QueueName, TQueueJobTypes } from "../queues";
import {
createNewRedisInstance,
getQueuePrefix,
redisQueueRetryOptions,
} from "./redis";
import { logger } from "../logger";
export class EntityChangeQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.EntityChangeQueue]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.EntityChangeQueue]
> | null {
if (EntityChangeQueue.instance) return EntityChangeQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
EntityChangeQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.EntityChangeQueue]>(
QueueName.EntityChangeQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.EntityChangeQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
: null;
EntityChangeQueue.instance?.on("error", (err) => {
logger.error("EntityChangeQueue error", err);
});
return EntityChangeQueue.instance;
}
}
@@ -1,7 +1,11 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
export class EvalExecutionQueue {
private static instance: Queue<
@@ -1,7 +1,11 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
export class ExperimentCreateQueue {
private static instance: Queue<
+8 -1
View File
@@ -21,6 +21,8 @@ import { BatchActionQueue } from "./batchActionQueue";
import { CreateEvalQueue } from "./createEvalQueue";
import { ScoreDeleteQueue } from "./scoreDelete";
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
import { WebhookQueue } from "./webhookQueue";
import { EntityChangeQueue } from "./entityChangeQueue";
// IngestionQueue is sharded and requires a sharding key
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
@@ -70,8 +72,13 @@ export function getQueue(
return ScoreDeleteQueue.getInstance();
case QueueName.DeadLetterRetryQueue:
return DeadLetterRetryQueue.getInstance();
case QueueName.WebhookQueue:
return WebhookQueue.getInstance();
case QueueName.EntityChangeQueue:
return EntityChangeQueue.getInstance();
default: {
const exhaustiveCheckDefault: never = queueName; // eslint-disable-line no-case-declarations, no-unused-vars
// eslint-disable-next-line no-case-declarations, no-unused-vars
const exhaustiveCheckDefault: never = queueName;
throw new Error(`Queue ${queueName} not found`);
}
}
@@ -73,7 +73,7 @@ export class IngestionQueue {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
attempts: 6,
backoff: {
type: "exponential",
delay: 5000,
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
import { env } from "../../env";
@@ -23,8 +27,8 @@ export class MeteringDataPostgresExportQueue {
MeteringDataPostgresExportQueue.instance = newRedis
? new Queue(QueueName.MeteringDataPostgresExportQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.MeteringDataPostgresExportQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.MeteringDataPostgresExportQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationProcessingQueue {
@@ -18,8 +22,8 @@ export class PostHogIntegrationProcessingQueue {
PostHogIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationProcessingQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationProcessingQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationProcessingQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -1,6 +1,10 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationQueue {
@@ -18,8 +22,8 @@ export class PostHogIntegrationQueue {
PostHogIntegrationQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationQueue, {
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationQueue),
connection: newRedis,
prefix: getQueuePrefix(QueueName.PostHogIntegrationQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class ProjectDeleteQueue {
+2 -2
View File
@@ -170,6 +170,6 @@ declare global {
var redis: undefined | ReturnType<typeof createRedisClient>; // eslint-disable-line no-unused-vars
}
export const redis = globalThis.redis ?? createRedisClient(); // eslint-disable-line no-undef
export const redis = globalThis.redis ?? createRedisClient();
if (env.NODE_ENV !== "production") globalThis.redis = redis; // eslint-disable-line no-undef
if (env.NODE_ENV !== "production") globalThis.redis = redis;
+20 -12
View File
@@ -1,12 +1,19 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class ScoreDeleteQueue {
private static instance: Queue<TQueueJobTypes[QueueName.ScoreDelete]> | null = null;
private static instance: Queue<TQueueJobTypes[QueueName.ScoreDelete]> | null =
null;
public static getInstance(): Queue<TQueueJobTypes[QueueName.ScoreDelete]> | null {
public static getInstance(): Queue<
TQueueJobTypes[QueueName.ScoreDelete]
> | null {
if (ScoreDeleteQueue.instance) return ScoreDeleteQueue.instance;
const newRedis = createNewRedisInstance({
@@ -20,16 +27,17 @@ export class ScoreDeleteQueue {
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.ScoreDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 30_000,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 30_000,
},
},
},
})
)
: null;
ScoreDeleteQueue.instance?.on("error", (err) => {
@@ -38,4 +46,4 @@ export class ScoreDeleteQueue {
return ScoreDeleteQueue.instance;
}
}
}
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class TraceDeleteQueue {
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions, getQueuePrefix } from "./redis";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class TraceUpsertQueue {
@@ -0,0 +1,45 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class WebhookQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.WebhookQueue]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.WebhookQueue]
> | null {
if (WebhookQueue.instance) return WebhookQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
WebhookQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.WebhookQueue]>(
QueueName.WebhookQueue,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
: null;
WebhookQueue.instance?.on("error", (err) => {
logger.error("WebhookQueue error", err);
});
return WebhookQueue.instance;
}
}
@@ -0,0 +1,242 @@
import {
Action,
ActionExecutionStatus,
JobConfigState,
prisma,
Trigger,
} from "../../db";
import {
TriggerEventSource,
WebhookActionConfigWithSecrets,
TriggerDomain,
TriggerEventAction,
ActionDomain,
AutomationDomain,
SafeWebhookActionConfig,
} from "../../domain/automations";
import { FilterState } from "../../types";
export const getActionByIdWithSecrets = async ({
projectId,
actionId,
}: {
projectId: string;
actionId: string;
}) => {
const actionConfig = await prisma.action.findFirst({
where: {
id: actionId,
projectId,
},
});
if (!actionConfig) {
return null;
}
const config = actionConfig.config as WebhookActionConfigWithSecrets;
return {
...actionConfig,
config: {
type: config.type,
url: config.url,
headers: config.headers,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
secretKey: config.secretKey,
},
};
};
export const getActionById = async ({
projectId,
actionId,
}: {
projectId: string;
actionId: string;
}): Promise<ActionDomain | null> => {
const actionConfig = await prisma.action.findFirst({
where: {
id: actionId,
projectId,
},
});
if (!actionConfig) {
return null;
}
const actionDomain = convertActionToDomain(actionConfig);
return actionDomain;
};
export type TriggerDomainWithActions = TriggerDomain & { actionIds: string[] };
export const getTriggerConfigurations = async ({
projectId,
eventSource,
status,
}: {
projectId: string;
eventSource: TriggerEventSource;
status: JobConfigState;
}): Promise<TriggerDomainWithActions[]> => {
const triggers = await prisma.trigger.findMany({
where: {
projectId,
eventSource,
status,
},
include: {
automations: {
include: {
action: true,
},
},
},
});
const triggerConfigurations = triggers.map((trigger) => ({
...convertTriggerToDomain(trigger),
actionIds: trigger.automations.map((automation) => automation.action.id),
}));
return triggerConfigurations;
};
const convertTriggerToDomain = (trigger: Trigger): TriggerDomain => {
return {
...trigger,
eventActions: (trigger.eventActions || []) as TriggerEventAction[],
filter: (trigger.filter || []) as FilterState,
eventSource: trigger.eventSource as TriggerEventSource,
};
};
const convertActionToDomain = (action: Action): ActionDomain => {
const config = action.config as WebhookActionConfigWithSecrets;
return {
...action,
config: {
type: config.type,
url: config.url,
headers: config.headers,
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
} as SafeWebhookActionConfig,
};
};
export const getAutomationById = async ({
projectId,
automationId,
}: {
projectId: string;
automationId: string;
}): Promise<AutomationDomain | null> => {
const automation = await prisma.automation.findFirst({
where: {
id: automationId,
projectId,
},
include: {
action: true,
trigger: true,
},
});
if (!automation) {
return null;
}
return {
id: automation.id,
name: automation.name,
trigger: convertTriggerToDomain(automation.trigger),
action: convertActionToDomain(automation.action),
};
};
export const getAutomations = async ({
projectId,
triggerId,
actionId,
}: {
projectId: string;
triggerId?: string;
actionId?: string;
}): Promise<AutomationDomain[]> => {
const automations = await prisma.automation.findMany({
where: {
projectId,
...(triggerId ? { triggerId } : {}),
...(actionId ? { actionId } : {}),
},
include: {
action: true,
trigger: true,
},
orderBy: {
createdAt: "desc",
},
});
return automations.map((automation) => ({
id: automation.id,
name: automation.name,
trigger: convertTriggerToDomain(automation.trigger),
action: convertActionToDomain(automation.action),
}));
};
export const getConsecutiveAutomationFailures = async ({
automationId,
projectId,
}: {
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,
},
});
if (!automation) {
return 0;
}
const { triggerId, actionId } = automation;
const executions = await prisma.automationExecution.findMany({
where: {
triggerId,
actionId,
projectId,
status: {
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
},
},
orderBy: {
createdAt: "desc",
},
take: 20,
select: {
status: true,
},
});
let consecutiveFailures = 0;
for (const execution of executions) {
if (execution.status === ActionExecutionStatus.ERROR) {
consecutiveFailures++;
} else if (execution.status === ActionExecutionStatus.COMPLETED) {
break; // Stop counting when we hit a successful execution
}
// Skip PENDING/CANCELLED executions in the count
}
return consecutiveFailures;
};
@@ -128,12 +128,12 @@ export const traceMtRecordInsertSchema = z.object({
id: z.string(),
start_time: z.number(),
end_time: z.number().nullish(),
name: z.string(),
name: z.string().nullish(),
// Metadata properties
metadata: z.record(z.string(), z.string()),
user_id: z.string(),
session_id: z.string(),
user_id: z.string().nullish(),
session_id: z.string().nullish(),
environment: z.string(),
tags: z.array(z.string()),
version: z.string().nullish(),
@@ -411,3 +411,128 @@ export const convertPostgresScoreToInsert = (
is_deleted: 0,
};
};
export const convertTraceToTraceMt = (
traceRecord: TraceRecordInsertType,
): TraceMtRecordInsertType => {
return {
// Identifiers
project_id: traceRecord.project_id,
id: traceRecord.id,
start_time: traceRecord.timestamp,
end_time: null, // traces don't have end_time, will be null
name: traceRecord.name || null,
// Metadata properties
metadata: traceRecord.metadata,
user_id: traceRecord.user_id || null,
session_id: traceRecord.session_id || null,
environment: traceRecord.environment,
tags: traceRecord.tags,
version: traceRecord.version || null,
release: traceRecord.release || null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: traceRecord.bookmarked ?? null,
public: traceRecord.public ?? null,
// Aggregations - empty for now, will be populated by aggregation processes
observation_ids: [],
score_ids: [],
cost_details: {},
usage_details: {},
// Input/Output
input: traceRecord.input || "",
output: traceRecord.output || "",
created_at: traceRecord.created_at,
updated_at: traceRecord.updated_at,
event_ts: traceRecord.event_ts,
};
};
export const convertObservationToTraceMt = (
observationRecord: ObservationRecordInsertType,
): TraceMtRecordInsertType => {
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
id: observationRecord.trace_id || "",
start_time: observationRecord.start_time,
end_time: observationRecord.end_time || null,
name: null,
// Metadata properties
metadata: {},
user_id: null,
session_id: null,
environment: observationRecord.environment,
tags: [],
version: null,
release: null,
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this observation ID
observation_ids: [observationRecord.id],
score_ids: [],
// We can fill the cost details here, but we shouldn't trust them.
// Only used for verification to estimate how big the double-counting is.
// Actually, we don't as this will make backfills challenging.
cost_details: {}, // observationRecord.cost_details || {},
usage_details: {}, // observationRecord.usage_details || {},
// Input/Output
input: "",
output: "",
created_at: observationRecord.created_at,
updated_at: observationRecord.updated_at,
event_ts: observationRecord.event_ts,
};
};
export const convertScoreToTraceMt = (
scoreRecord: ScoreRecordInsertType,
): TraceMtRecordInsertType => {
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
id: scoreRecord.trace_id || "",
start_time: scoreRecord.timestamp,
end_time: null, // scores don't have end_time
name: null,
// Metadata properties
metadata: {},
user_id: null,
session_id: null,
environment: scoreRecord.environment,
tags: [], // scores don't have tags
version: null, // scores don't have version
release: null, // scores don't have release
// UI properties - nullable to prevent absent values being interpreted as overwrites
bookmarked: null,
public: null,
// Aggregations - include this score ID
observation_ids: [],
score_ids: [scoreRecord.id],
cost_details: {},
usage_details: {},
// Input/Output
input: "", // scores don't have input
output: "", // scores don't have output
created_at: scoreRecord.created_at,
updated_at: scoreRecord.updated_at,
event_ts: scoreRecord.event_ts,
};
};
@@ -12,3 +12,4 @@ export * from "./trace-sessions";
export * from "./scores-utils";
export * from "./blobStorageLog";
export * from "./environments";
export * from "./automation-repository";
+317 -123
View File
@@ -28,6 +28,41 @@ import {
import { env } from "../../env";
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
import { recordDistribution } from "../instrumentation";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
// eslint-disable-next-line no-unused-vars
enum TracesAMTs {
Traces7dAMT = "traces_7d_amt", // eslint-disable-line no-unused-vars
Traces30dAMT = "traces_30d_amt", // eslint-disable-line no-unused-vars
TracesAllAMT = "traces_all_amt", // eslint-disable-line no-unused-vars
}
/**
* Returns which AMT table to use given the timestamp.
* For <= 6 days, we use traces_7d_amt,
* for <= 29 days, we use traces_30d_amt,
* for all other cases we use traces_all_amt.
*
* @param fromTimestamp
*/
export const getTimeframesTracesAMT = (
fromTimestamp: Date | undefined,
): TracesAMTs => {
if (!fromTimestamp) {
return TracesAMTs.TracesAllAMT;
}
const now = new Date();
const diffInDays = Math.floor(
(now.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffInDays <= 6) {
return TracesAMTs.Traces7dAMT;
} else if (diffInDays <= 29) {
return TracesAMTs.Traces30dAMT;
}
return TracesAMTs.TracesAllAMT;
};
/**
* Checks if trace exists in clickhouse.
@@ -82,66 +117,104 @@ export const checkTraceExists = async ({
const tracesFilterRes = tracesFilter.apply();
const observationFilterRes = observationFilter?.apply();
const query = `
const observations_cte = `
WITH observations_agg AS (
SELECT
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS aggregated_level,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
trace_id,
project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
SELECT
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS aggregated_level,
countIf(level = 'ERROR') as error_count,
countIf(level = 'WARNING') as warning_count,
countIf(level = 'DEFAULT') as default_count,
countIf(level = 'DEBUG') as debug_count,
trace_id,
project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
AND o.start_time >= {timestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}
GROUP BY trace_id, project_id
)
SELECT
t.id as id,
t.project_id as project_id
FROM traces t FINAL
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
GROUP BY t.id, t.project_id
GROUP BY trace_id, project_id
)
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
projectId,
...tracesFilterRes.params,
...(observationFilterRes ? observationFilterRes.params : {}),
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
...(maxTimeStamp
? { maxTimeStamp: convertDateToClickhouseDateTime(maxTimeStamp) }
: {}),
...(exactTimestamp
? { exactTimestamp: convertDateToClickhouseDateTime(exactTimestamp) }
: {}),
return measureAndReturn({
operationName: "checkTraceExists",
projectId,
input: {
params: {
projectId,
...tracesFilterRes.params,
...(observationFilterRes ? observationFilterRes.params : {}),
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
...(maxTimeStamp
? { maxTimeStamp: convertDateToClickhouseDateTime(maxTimeStamp) }
: {}),
...(exactTimestamp
? { exactTimestamp: convertDateToClickhouseDateTime(exactTimestamp) }
: {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "exists",
projectId,
},
timestamp: timestamp ?? exactTimestamp,
},
tags: {
feature: "tracing",
type: "trace",
kind: "exists",
projectId,
existingExecution: async (input) => {
const query = `
${observations_cte}
SELECT
t.id as id,
t.project_id as project_id
FROM traces t FINAL
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
GROUP BY t.id, t.project_id
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
});
return rows.length > 0;
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
${observations_cte}
SELECT
t.id as id,
t.project_id as project_id,
-- Add a timestamp alias to ensure we can filter on it
t.start_time as timestamp
FROM ${traceAmt} t
${observationFilterRes ? `INNER JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id` : ""}
WHERE ${tracesFilterRes.query}
AND t.project_id = {projectId: String}
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: input.params,
tags: input.tags,
});
return rows.length > 0;
},
});
return rows.length > 0;
};
/**
@@ -171,28 +244,77 @@ export const getTracesByIds = async (
timestamp?: Date,
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined,
) => {
const query = `
SELECT *
FROM traces
WHERE id IN ({traceIds: Array(String)})
AND project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<TraceRecordReadType>({
query,
params: {
traceIds,
projectId,
timestamp: timestamp ? convertDateToClickhouseDateTime(timestamp) : null,
const records = await measureAndReturn({
operationName: "getTracesByIds",
projectId,
input: {
params: {
traceIds,
projectId,
timestamp: timestamp
? convertDateToClickhouseDateTime(timestamp)
: null,
},
tags: {
feature: "tracing",
type: "trace",
kind: "byId",
projectId,
},
clickhouseConfigs,
},
tags: {
feature: "tracing",
type: "trace",
kind: "byId",
projectId,
existingExecution: (input) => {
const query = `
SELECT *
FROM traces
WHERE id IN ({traceIds: Array(String)})
AND project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
clickhouseConfigs: input.clickhouseConfigs,
});
},
newExecution: (input) => {
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
release as release,
version as version,
project_id,
environment,
finalizeAggregation(public) as public,
finalizeAggregation(bookmarked) as bookmarked,
tags,
finalizeAggregation(input) as input,
finalizeAggregation(output) as output,
session_id as session_id,
0 as is_deleted,
start_time as timestamp,
created_at,
updated_at,
updated_at as event_ts
FROM traces_all_amt
WHERE id IN ({traceIds: Array(String)})
AND project_id = {projectId: String}
LIMIT 1 BY project_id, id
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
clickhouseConfigs: input.clickhouseConfigs,
});
},
clickhouseConfigs,
});
return records.map(convertClickhouseToDomain);
@@ -204,11 +326,11 @@ export const getTracesBySessionId = async (
timestamp?: Date,
) => {
const query = `
SELECT *
SELECT *
FROM traces
WHERE session_id IN ({sessionIds: Array(String)})
AND project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<TraceRecordReadType>({
@@ -239,27 +361,55 @@ export const getTracesBySessionId = async (
};
export const hasAnyTrace = async (projectId: string) => {
const query = `
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
return measureAndReturn({
operationName: "hasAnyTrace",
projectId,
input: {
projectId,
tags: {
feature: "tracing",
type: "trace",
kind: "hasAny",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "hasAny",
projectId,
existingExecution: async (input) => {
const query = `
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId: input.projectId,
},
tags: input.tags,
});
return rows.length > 0;
},
newExecution: async (input) => {
const query = `
SELECT 1
FROM traces_all_amt
WHERE project_id = {projectId: String}
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId: input.projectId,
},
tags: input.tags,
});
return rows.length > 0;
},
});
return rows.length > 0;
};
export const getTraceCountsByProjectInCreationInterval = async ({
@@ -270,7 +420,7 @@ export const getTraceCountsByProjectInCreationInterval = async ({
end: Date;
}) => {
const query = `
SELECT
SELECT
project_id,
count(*) as count
FROM traces
@@ -306,7 +456,7 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
start: Date;
}) => {
const query = `
SELECT
SELECT
count(*) as count
FROM traces
WHERE project_id IN ({projectIds: Array(String)})
@@ -347,34 +497,78 @@ export const getTraceById = async ({
timestamp?: Date;
fromTimestamp?: Date;
}) => {
const query = `
SELECT *
FROM traces
WHERE id = {traceId: String}
AND project_id = {projectId: String}
${timestamp ? `AND toDate(timestamp) = toDate({timestamp: DateTime64(3)})` : ""}
${fromTimestamp ? `AND timestamp >= {fromTimestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1
`;
const records = await queryClickhouse<TraceRecordReadType>({
query,
params: {
traceId,
projectId,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
...(fromTimestamp
? { fromTimestamp: convertDateToClickhouseDateTime(fromTimestamp) }
: {}),
const records = await measureAndReturn({
operationName: "getTraceById",
projectId,
input: {
params: {
traceId,
projectId,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
...(fromTimestamp
? { fromTimestamp: convertDateToClickhouseDateTime(fromTimestamp) }
: {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "byId",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "byId",
projectId,
existingExecution: (input) => {
const query = `
SELECT *
FROM traces
WHERE id = {traceId: String}
AND project_id = {projectId: String}
${timestamp ? `AND toDate(timestamp) = toDate({timestamp: DateTime64(3)})` : ""}
${fromTimestamp ? `AND timestamp >= {fromTimestamp: DateTime64(3)}` : ""}
ORDER BY event_ts DESC
LIMIT 1
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
});
},
newExecution: (input) => {
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
release as release,
version as version,
project_id,
environment,
finalizeAggregation(public) as public,
finalizeAggregation(bookmarked) as bookmarked,
tags,
finalizeAggregation(input) as input,
finalizeAggregation(output) as output,
session_id as session_id,
0 as is_deleted,
start_time as timestamp,
created_at,
updated_at,
updated_at as event_ts
FROM traces_all_amt
WHERE id = {traceId: String}
AND project_id = {projectId: String}
LIMIT 1
`;
return queryClickhouse<TraceRecordReadType>({
query,
params: input.params,
tags: input.tags,
});
},
});
@@ -409,7 +603,7 @@ export const getTracesGroupedByName = async (
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
select
name as name,
count(*) as count
from traces t FINAL
@@ -466,7 +660,7 @@ export const getTracesGroupedByUsers = async (
// We mainly use queries like this to retrieve filter options.
// Therefore, we can skip final as some inaccuracy in count is acceptable.
const query = `
select
select
user_id as user,
count(*) as count
from traces t
@@ -948,7 +1142,7 @@ export const getTracesForPostHog = async function* (
GROUP BY o.project_id, o.trace_id
)
SELECT
SELECT
t.id as id,
t.timestamp as timestamp,
t.name as name,
@@ -15,6 +15,9 @@ export class InMemoryFilterService {
filter: FilterState,
fieldMapper: (data: T, column: string) => unknown, // eslint-disable-line no-unused-vars
): boolean {
logger.debug(
`Evaluating filter ${JSON.stringify(filter)} for data ${JSON.stringify(data)}`,
);
try {
// If no filters, data matches
if (!filter || filter.length === 0) {
@@ -42,7 +42,8 @@ export interface StorageService {
asAttachment?: boolean, // eslint-disable-line no-unused-vars
): Promise<string>;
getSignedUploadUrl(params: { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
@@ -213,7 +214,7 @@ class AzureBlobStorageService implements StorageService {
}
private async streamToString(
readableStream: NodeJS.ReadableStream, // eslint-disable-line no-undef
readableStream: NodeJS.ReadableStream,
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
@@ -66,10 +66,11 @@ const ResetPasswordTemplate = ({ token }: ResetPasswordTemplateProps) => {
export async function sendResetPasswordVerificationRequest(
params: SendVerificationRequestParams,
) {
const { identifier, token, provider } = params as SendVerificationRequestParams & { token: string };
const { identifier, token, provider } =
params as SendVerificationRequestParams & { token: string };
const transport = createTransport(provider.server);
const htmlTemplate = await render(<ResetPasswordTemplate token={token} />);
const result = await transport.sendMail({
to: identifier,
from: provider.from,
@@ -3,9 +3,20 @@ import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
convertTraceToTraceMt,
} from "../repositories/definitions";
import { env } from "../../env";
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
if (
env.LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES === "true"
) {
await clickhouseClient().insert({
table: "traces_mt",
format: "JSONEachRow",
values: trace.map(convertTraceToTraceMt),
});
}
return await clickhouseClient().insert({
table: "traces",
format: "JSONEachRow",
@@ -24,12 +24,13 @@ export class DatabaseReadStream<EntityType> extends Readable {
constructor(
// the delegate function takes care of querying the database in a paginated manner
private queryDelegate: ( // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
private queryDelegate: (
pageSize: number, // eslint-disable-line no-unused-vars
offset: number // eslint-disable-line no-unused-vars
offset: number, // eslint-disable-line no-unused-vars
) => Promise<Array<EntityType>>,
private pageSize: number, // eslint-disable-line no-unused-vars
private maxRecords?: number // eslint-disable-line no-unused-vars
private maxRecords?: number, // eslint-disable-line no-unused-vars
) {
super({ objectMode: true }); // Set object mode to true to allow pushing objects to the stream rather than strings or buffers
@@ -1,5 +1,5 @@
export const stringify = (data: any): string => {
return JSON.stringify(data, (key, value) =>
typeof value === "bigint" ? Number.parseInt(value.toString()) : value
typeof value === "bigint" ? Number.parseInt(value.toString()) : value,
);
};
@@ -11,7 +11,7 @@ export function transformStreamToCsv(): Transform {
objectMode: true,
transform(
row: Record<string, any>,
encoding: BufferEncoding, // eslint-disable-line no-undef
encoding: BufferEncoding, // eslint-disable-line
callback: TransformCallback,
): void {
if (isFirstChunk) {
@@ -9,7 +9,7 @@ export function transformStreamToJson(): Transform {
transform(
row: any,
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
encoding: BufferEncoding, // eslint-disable-line no-unused-vars
callback: TransformCallback,
): void {
if (isFirstElement) {
@@ -7,7 +7,7 @@ export function transformStreamToJsonl(): Transform {
transform(
row: Record<string, any>,
encoding: BufferEncoding, // eslint-disable-line no-undef, no-unused-vars
encoding: BufferEncoding, // eslint-disable-line no-unused-vars
callback: TransformCallback,
): void {
this.push(stringify(row) + "\n");
@@ -4,3 +4,4 @@ export * from "./mapObservationsTable";
export * from "./mapTracesTable";
export * from "./mapDashboards";
export * from "./mapScoresTable";
export * from "./promptsTable";
@@ -1,9 +1,6 @@
import {
type ColumnDefinition,
PromptType,
type SingleValueOption,
formatColumnOptions,
} from "@langfuse/shared";
import { PromptType } from "../features/prompts/types";
import { formatColumnOptions } from "./typeHelpers";
import { ColumnDefinition, SingleValueOption } from "./types";
export const promptsTableCols: ColumnDefinition[] = [
{
@@ -51,6 +48,12 @@ export const promptsTableCols: ColumnDefinition[] = [
internal: 'p."tags"',
options: [], // to be added at runtime
},
{
name: "Config",
id: "config",
type: "stringObject",
internal: 'p."config"',
},
];
export type PromptOptions = {
@@ -71,3 +74,7 @@ export function promptsTableColsWithOptions(
return col;
});
}
export function webhookActionFilterOptions(): ColumnDefinition[] {
return promptsTableCols.filter((col) => col.id === "name");
}
+1
View File
@@ -24,6 +24,7 @@ export type FilterOption = {
value: string;
count?: number;
displayValue?: string; // FIX: Temporary workaround: Used to display a different value than the actual value since multiSelect doesn't support key-value pairs
description?: string;
};
export type TableName =
+1 -1
View File
@@ -6,7 +6,7 @@ type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export function removeObjectKeys<T, K extends keyof T>(
obj: T,
keys: K[]
keys: K[],
): OmitKeys<T, K> {
const result = { ...obj };
for (const key of keys) {
+4 -3
View File
@@ -17,8 +17,9 @@ export interface PromptMessage {
*/
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string"
.filter(
(msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string",
)
.map(msg => msg.name);
.map((msg) => msg.name);
}
+207 -176
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
/** @type {import("prettier").Config} */
const config = {
trailingComma: "all",
/** printWidth: 100, Commenting out for now to prevent large number of changes. **present in .vscode/settings.json though** - TODO: change to consistence with 100chars */
};
module.exports = config;
+9 -34
View File
@@ -1,25 +1,14 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
".env"
],
"globalDependencies": [".env"],
"envMode": "loose",
"tasks": {
"build": {
"dependsOn": [
"db:generate",
"^build"
],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
]
"dependsOn": ["db:generate", "^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"start": {
"dependsOn": [
"^start"
]
"dependsOn": ["^start"]
},
"db:migrate": {
"cache": false
@@ -32,41 +21,27 @@
"dev": {
"cache": false,
"persistent": true,
"dependsOn": [
"db:generate",
"@langfuse/shared#build"
]
"dependsOn": ["db:generate", "@langfuse/shared#build"]
},
"dev:worker": {
"cache": false,
"persistent": true,
"dependsOn": [
"db:generate",
"@langfuse/shared#build"
]
"dependsOn": ["db:generate", "@langfuse/shared#build"]
},
"dev:web": {
"cache": false,
"persistent": true,
"dependsOn": [
"db:generate",
"@langfuse/shared#build"
]
"dependsOn": ["db:generate", "@langfuse/shared#build"]
},
"db:generate": {
"cache": false,
"dependsOn": [
"^db:generate"
]
"dependsOn": ["^db:generate"]
},
"lint": {
"cache": false
},
"test": {
"dependsOn": [
"^test",
"db:generate"
]
"dependsOn": ["^test", "db:generate"]
}
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
fi
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.2/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
mv migrate /usr/bin/migrate
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
+1 -1
View File
@@ -22,7 +22,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;
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;
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;` : ""}
+9 -10
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.78.2",
"version": "3.80.0",
"private": true,
"license": "MIT",
"engines": {
@@ -11,15 +11,14 @@
"dev": "dotenv -e ../.env -- next dev",
"lint": "dotenv -e ../.env -- next lint --max-warnings 0",
"lint:fix": "dotenv -e ../.env -- next lint --fix",
"prettier": "prettier --write ./src *.{ts,js}",
"clean": "rm -rf node_modules",
"start": "dotenv -e ../.env -- sh -c 'NEXT_MANUAL_SIG_HANDLE=true next start'",
"test": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects async-server",
"test-sync": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects sync-server",
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --silent false --verbose false --runInBand --detectOpenHandles --selectProjects client",
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env -- playwright test --reporter=line",
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
"test": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --verbose --runInBand --detectOpenHandles --selectProjects async-server",
"test-sync": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --verbose --runInBand --detectOpenHandles --selectProjects sync-server",
"test-client": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --verbose --runInBand --detectOpenHandles --selectProjects client",
"test:watch": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --watch --runInBand",
"test:e2e": "dotenv -e ../.env.test -e ../.env -- playwright test --reporter=line",
"test:e2e:server": "cross-env NODE_OPTIONS='--no-experimental-require-module' dotenv -e ../.env.test -e ../.env -- jest --runInBand --detectOpenHandles --verbose --selectProjects e2e-server"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
@@ -190,8 +189,8 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"node-mocks-http": "^1.14.1",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.13",
"tailwindcss": "^3.4.17",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
+5 -6
View File
@@ -875,14 +875,13 @@ paths:
schema:
type: integer
nullable: true
- name: response
in: query
required: true
schema:
$ref: '#/components/schemas/PaginatedDatasetRunItems'
responses:
'204':
'200':
description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedDatasetRunItems'
'400':
description: ''
content:

Some files were not shown because too many files have changed in this diff Show More