Compare commits

...
221 Commits
Author SHA1 Message Date
steffen911 fd37e2c35e chore: release v3.2.0 2024-12-16 18:04:14 +01:00
marliessophieandGitHub 0352e7fad6 chore(annotation): return previous score props in delete method (#4737)
chore: return previous annotation score in delete method
2024-12-16 16:47:37 +00:00
Hassieb PakzadandGitHub 6c4482ff56 feat(cost-tracking): allow additional cost and usage keys (#4536) 2024-12-16 17:30:24 +01:00
marliessophieandGitHub cc62b32b82 fix(annotation): fail postgres upsert, delete silentley while Clickhouse is in lead (#4733)
* fix(annotation): fail postgres upsert, delete silentley while Clickhouse is in lead

* chore: disable deletion of score while update operation is in progress
2024-12-16 14:40:04 +00:00
Steffen SchmitzandGitHub b421c2dec5 feat: add posthog integration processing (#4685)
* feat: add posthog integration processing

* chore: add times for query optimization

* chore: cleanups

* chore: remove unused join

* chore: run posthog integration every hour

* chore: rename to langfuse_event_version

* chore: add date workaround for initial sync

* chore: query updates

* chore: remove scheduling info
2024-12-16 13:43:07 +00:00
Marc KlingenandGitHub 3a67533ac8 feat(ui): new experiment modal to highlight prompt and custom experiments (#4731)
* feat(ui): new experiment modal to highlight prompt and custom experiments

* push

* fix lint

* fix lint
2024-12-16 12:00:22 +00:00
steffen911 21303a275a chore: release v3.1.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-12-16 11:03:21 +01:00
Steffen SchmitzandGitHub 284494c62a fix: use correct golang-migrate in build (#4729)
* fix: use correct golang-migrate in build

* chore: skip docker push

* chore: run docker build right away

* chore: add args for targetplatform and buildplatform

* chore: add TARGETPLATFORM args
2024-12-16 09:44:58 +00:00
Marc KlingenandGitHub b37ec03483 fix(ui): redirect after org/project deletion when using custom base path (#4727) 2024-12-16 09:18:53 +00:00
Steffen SchmitzandGitHub a49a410a04 chore: remove filterlist console log (#4718) 2024-12-13 15:48:09 +00:00
Steffen SchmitzandGitHub 1e9d8de305 fix: rate limit deletions globally to 1 per 3s (#4715) 2024-12-13 10:09:53 +00:00
Marc KlingenandGitHub 0fa88aea57 feat: in HF Spaces, prompt opening in new tab when running in iframe (#4713) 2024-12-13 02:41:37 +01:00
Max DeichmannandGitHub 61240d7c92 fix: fix pagination on public api (#4710)
* fix: fix pagination

* fix: fix pagination

* fix: fix pagination

* fix: fix pagination

* fix: fix pagination

* fix: fix pagination
2024-12-12 23:02:46 +00:00
marliessophieandGitHub d54c888cd9 chore(evals/experiments): support retries via bullmq; set 2 minute LLM call completion timeouts (#4709)
* chore(eval/experiments): support job retries via bullmq instead of within worker

* chore: timeout model completion calls after 2 minutes

* chore: set retries on llm completion calls to 1

* chore: do not propogate expected error cases in experiment creation

* eslint
2024-12-12 20:53:56 +00:00
Max DeichmannandGitHub b87a00fa03 fix: do not fetch dataset run items which are not active (#4708)
* fix

* fix

* push

* fix
2024-12-12 20:31:43 +00:00
marliessophieandGitHub 8b8d1086e5 fix(organizations): block org deletion in case of active projects (#4707)
* fix(organizations): block org deletion in case of active projects

* chore: count soft and hard deleted projects as
2024-12-12 19:23:27 +00:00
Max DeichmannandGitHub d88902316b fix: fix level filter on trace upsert queue (#4704) 2024-12-12 15:40:47 +01:00
Steffen SchmitzandGitHub 5dd5adf850 fix: use clickhouse for failIfRecentEvent condition in healthcheck (#4701) 2024-12-12 12:22:19 +00:00
Max Deichmann a56246059c chore: release v3.1.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-12-12 13:34:26 +01:00
Max DeichmannandGitHub 833f4165ce chore: improve naming for observations by level chart (#4700)
* feat: add observations status chart

* fix: improve naming for observations by level chart
2024-12-12 12:20:18 +00:00
Max DeichmannandGitHub ebe0c034fd security: upgrade next-auth (#4690)
* upgrade next-auth

* push

* eval execution time
2024-12-12 12:14:33 +00:00
Max DeichmannandGitHub 64492f3002 feat: add observations status chart (#4697) 2024-12-12 12:56:53 +01:00
Max DeichmannandGitHub 27faebe244 chore: improve trace export tests (#4694) 2024-12-12 10:15:37 +00:00
Marc KlingenandGitHub 5e681e5a6d feat(ui): add editable note for each api key (#4686)
* feat(ui): add editable note for each api key

* fix access scopes
2024-12-11 16:51:17 +00:00
marliessophieandGitHub 8af88a0cec fix(playground): Model side menu in playground not scrollable (#4687) 2024-12-11 16:48:18 +00:00
marliessophieandGitHub 1770579fa6 feat(annotation): optimistic updates on annotation form (#3859)
* wip: optimistic updates on annotation form

* refactor to handle sequence of create, update operations correctly

* fix: don't check postgres for score existence when running on clickhouse

* disable annotation button while score updates are saving

* fix: await for all mutations to complete before opening modal again

* rm console log

* fix: update api response clickhouse
2024-12-11 12:39:05 +00:00
Max DeichmannandGitHub 273d29c383 fix: export i/o for traces (#4673) 2024-12-10 16:21:08 +01:00
marliessophieandGitHub 0047a3dec6 fix(experiments): don't require name in experiment creation (#4659)
* fix(experiments): don't require name in experiment creation

* push

* push

* push

* push
2024-12-10 15:00:15 +00:00
Hassieb PakzadandGitHub b63aeeeae7 fix(vertex): at least 1 user message always (#4666) 2024-12-10 10:46:17 +01:00
Marc KlingenandGitHub f5dc7eea07 docs: update upgrade guide url in entrypoint (#4655) 2024-12-09 13:36:27 +00:00
Steffen SchmitzandGitHub 8560a10e53 fix: handle non-existent projects gracefully on delete (#4654)
* fix: handle non-existent projects gracefully on delete

* chore: return early on project does not exist
2024-12-09 13:14:20 +00:00
Marc KlingenandGitHub 609a330c37 chore: update default docker compose to use 3 docker tag (#4653) 2024-12-09 13:31:58 +01:00
Hassieb PakzadandGitHub 07ad7e54e3 fix(model-form): increase max token limit to 16384 (#4651) 2024-12-09 11:52:18 +01:00
Max DeichmannandGitHub 4781b91e90 fix: always restart all docker services (#4649)
fix
2024-12-09 10:10:27 +00:00
marliessophieandGitHub 2c68223402 fix(experiments): ensure experiment runs on all valid inputs (#4643)
* fix(experiments): ensure experiment runs on all valid inputs

* refactor: extract common function
2024-12-09 10:01:18 +00:00
Max Deichmann 1211de3efb chore: release v3.0.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-12-09 11:15:37 +01:00
Zhiqiang ZHOUandGitHub 9b9fd35f13 fix(openapi): MediaContentType to enum (#4627)
Signed-off-by: STRRL <im@strrl.dev>
2024-12-09 11:13:00 +01:00
Steffen SchmitzandGitHub 407f5dd1ce build: add latest tags to v3 tags (#4648) 2024-12-09 09:59:32 +00:00
Steffen SchmitzandGitHub d7832336dd fix: allow userId filter on /api/public/observations again (#4644)
* fix: allow userId filter on /api/public/observations again

* Update observation-api.servertest.ts
2024-12-09 09:46:51 +00:00
Max DeichmannandGitHub 6362d8bcc9 fix: always pull the project_id for generations queries (#4641)
push
2024-12-09 08:19:15 +00:00
Max Deichmann 35057ea443 chore: release v3.0.0-rc.6
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-09 08:52:46 +01:00
Max DeichmannandGitHub 9adc3bee5b fix: default to empty string for csv export in case of null (#4640) 2024-12-08 22:56:48 +01:00
Max DeichmannandGitHub e57af47d84 fix: fix trace tagging without postgres (#4639)
* fix: fix trace tagging without postgres

* fix: fix trace tagging without postgres
2024-12-08 21:43:00 +00:00
Max DeichmannandGitHub 8f0247d311 fix: fix trace bookmarking (#4638)
fix: fix trace publishing
2024-12-08 22:22:07 +01:00
Max DeichmannandGitHub e96b2a4b0f feat: move exports to ch (#4634) 2024-12-08 22:07:33 +01:00
Max DeichmannandGitHub c389d4f01a feat: move session tests to async suite (#4636)
* feat: move session tests to async suite

* feat: move session tests to async suite

* feat: move session tests to async suite
2024-12-08 13:21:14 +00:00
Max Deichmann 4d8bb0fb95 chore: release v3.0.0-rc.5
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-08 10:52:56 +01:00
Marc KlingenandGitHub 79068ef93f chore: use s3 region auto in docker-compose.yml (#4630) 2024-12-07 17:26:43 +00:00
6c1c231ba7 fix: support reading observations from clickhouse for observations in annotation queue (#4621)
Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2024-12-07 18:10:20 +01:00
Max DeichmannandGitHub b722892c00 fix: check postgres negative flag (#4624)
* fix: check postgres negative flag

* improve dashboard

* improve dashboard

* improve dashboard

* improve dashboard

* perf: fetch observations using timestamps

* perf: fetch observations using timestamps

* perf: fetch observations using timestamps

* perf: fetch observations using timestamps
2024-12-07 14:52:55 +00:00
Max DeichmannandGitHub 4cd1d167da perf: fetch observations using timestamps (#4629) 2024-12-07 13:35:45 +00:00
Steffen SchmitzandGitHub c0f38a8eda chore: hide background migrations on cloud and show queued status (#4628) 2024-12-07 07:59:02 +00:00
marliessophieandGitHub 6be87994a9 fix: only read trace and score from prisma if LANGFUSE_POSTGRES_INGESTION_ENABLED (#4618)
* chore: remove reading trace and score from prisma, only read from clickhouse

* fixup: keep create logic behind flag

* chore: only read from postgres for updating annoation score if `LANGFUSE_POSTGRES_INGESTION_ENABLED`

* chore: only read from postgres for deleting annoation score if `LANGFUSE_POSTGRES_INGESTION_ENABLED`

* chore: throw errors if scores not found in CH

* chore: add audit logs in clickhouse scenario

* only create audit log once on cloud
2024-12-06 18:42:01 +00:00
Max DeichmannandGitHub e90bb60ec8 fix: fix traces publishing in V3 without postgres (#4620)
* make sleep great again

* make sleep great again
2024-12-06 18:22:53 +00:00
Max DeichmannandGitHub 439a65b926 fix: fix numeric chart for dashboards (#4615) 2024-12-06 18:15:25 +00:00
Max DeichmannandGitHub 11af1f4167 feat: increase trace upsert delay (#4619)
* fix: increase trace upsert delay

* fix: increase trace upsert delay
2024-12-06 17:15:23 +00:00
marliessophieandGitHub 2d265eb3f9 feat(comments): add support for comment button on each trace preview in session view (#3848)
* feat(comments): add support for comment button on each trace preview in single session view

* perf: manually timeout comment count query at 500ms without failing overall operation

* push

* chore: query comment reference objects in clickhouse if `env.clickhouse_url`

* remove: deprecated code

* chore: support fetching traces per session from clickhouse

* push

* refactor: adjust tests to populate traces and observations in clickhouse

* refactor: adjust tests and remove redundant code

* push
2024-12-06 16:35:15 +00:00
Max DeichmannandGitHub a93b8ff119 chore: move eval to correct ee directory (#4616)
* chore: move eval to correct ee directory

* chore: move eval to correct ee directory

* chore: move eval to correct ee directory
2024-12-06 16:31:10 +00:00
Max DeichmannandGitHub 81cf48ddf0 feat: set v3 default variables (#4613) 2024-12-06 16:46:09 +01:00
816eec0f75 chore: drop FK v3 constraints (#4612)
* chore: drop FK v3 constraints

* make sleep great again

* make sleep great again

* make sleep great again

* make sleep great again

---------

Co-authored-by: steffen911 <steffen@langfuse.com>
2024-12-06 13:02:54 +00:00
steffen911 fa630d34de chore: release v3.0.0-rc.4
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-06 14:01:36 +01:00
0eb129e186 feat: add ui for background migrations (#4602)
* feat: add ui for background migrations

* chore: add retry for background migrations

* chore: remove redundant import

* chore: invalidate correct state

* chore: unused import

* chore: update the popup text

* chore: add status icon

* chore: move to update menu

---------

Co-authored-by: steffen911 <steffen@langfuse.com>
2024-12-06 12:49:26 +00:00
marliessophieandGitHub 2defaf273a fix(experiments): don't create experiment run items for archived dataset items (#4611)
* fix(experiments): don't create experiment run items for archived dataset items

* fix: type check for input, `typeof null === "object"` in js
2024-12-06 12:02:46 +00:00
Max DeichmannandGitHub b9305e76f5 fix: fix front end confirmations to delete traces (#4601) 2024-12-05 16:55:28 +01:00
Max DeichmannandGitHub 241d1119f9 feat: adjust trace redirect to uniqueness of trace ids to projects (#4598) 2024-12-05 15:24:11 +00:00
Steffen SchmitzandGitHub 4a44adcedd chore: add wait time during validation of clickhouse background migra… (#4596)
chore: add wait time during validation of clickhouse background migrations
2024-12-05 13:53:09 +00:00
Steffen SchmitzandGitHub 9a861dff37 chore: make project deletion asynchronous (#4399)
* chore: make project deletion asynchronous

* chore: delete projects on the worker

* chore: indicate in UI overview that project is being deleted

* chore: remove API keys from Postgres in project deletion call
2024-12-05 13:13:22 +00:00
Steffen SchmitzandGitHub 12ee00f273 chore: check early whether clickhouse url is configured (#4595)
* chore: check early whether clickhouse url is configured

* Update entrypoint.sh
2024-12-05 12:51:23 +00:00
Steffen SchmitzandGitHub f2513fbebf fix: return timestamps in API response as ISO strings (#4593) 2024-12-05 10:49:22 +00:00
marliessophieandGitHub 5be98906e3 feat(url): support AWS S3 signed images in trace/observation preview (#4575)
* feat(url): support AWS S3 signed images in trace/observation preview

* push

* push

* chore: also validate content-type of pre-signed urls
2024-12-05 09:35:20 +00:00
marliessophieandGitHub 4bac94e3fc feat(comments): increase content character limit to 3,000 (#4577)
* feat(comments): increase content character limit to 3,000

* push

* push

* push
2024-12-05 08:38:43 +00:00
Max DeichmannandGitHub 86980da792 fix: show correct scores if dataset runs link same traces (#4582)
* fix: fix scores when dataset runs are linked to the same traces

* fix: fix scores when dataset runs are linked to the same traces
2024-12-04 22:14:18 +00:00
Max DeichmannandGitHub f9be079d62 chore: add aggregateScores tests (#4580)
* chore: add aggregateScores tests

* chore: add aggregateScores tests
2024-12-04 21:06:43 +00:00
Marc KlingenandGitHub d66fac5e29 chore: explicitly mention worker/src/ee (#4581) 2024-12-04 21:02:51 +00:00
Hassieb PakzadandGitHub 83c01bb444 fix(media): add self host env vars (#4578) 2024-12-04 19:07:37 +01:00
Hassieb PakzadandGitHub c937fc7280 chore(cost-tracking): add gpt-4o-2024-11-20 prices (#4576) 2024-12-04 17:10:36 +00:00
Hassieb PakzadandGitHub c8aa4a2d70 chore(ingestion): add back async ingestion processing flag (#4573) 2024-12-04 17:28:18 +01:00
Marc KlingenandGitHub 5d459f0143 fix(cloud): only active evals count towards free plan limit (#4572)
* fix(cloud): only active evals count towards free plan limit

* remove log
2024-12-04 15:22:45 +00:00
Steffen SchmitzandGitHub faa1b71f48 fix: perform batch trace deletion on clickhouse (#4570) 2024-12-04 14:17:09 +00:00
Max DeichmannandGitHub 99e9825e66 perf: improve user metrics for large projects (#4568)
* feat: improve user metrics

* feat: improve user metrics

* feat: improve user metrics

* feat: improve user metrics

* feat: improve user metrics
2024-12-04 14:11:28 +00:00
marliessophieandGitHub 11ad444632 fix(ui): show scrollbar for scores in column visibility (#4569) 2024-12-04 13:56:56 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
f132aa3969 fix: convert json strings to json in traces api metadata field (#4564)
* fix: convert json strings to json in traces api metadata field

* Update web/src/__tests__/async/traces-api.servertest.ts

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

* chore: stupid ai

* chore: typing

* chore: lint

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2024-12-04 11:11:43 +00:00
Steffen SchmitzandGitHub fa69d4dc75 chore: increase timeout for daily metrics query (#4565) 2024-12-04 10:46:52 +00:00
Max DeichmannandGitHub 6ef6fba4dc chore: double ingestion API rate limits (#4544)
feat
2024-12-04 09:31:44 +00:00
Max DeichmannandGitHub a5078ccbaf chore: remove public users api (#4553) 2024-12-04 09:20:09 +00:00
Marc KlingenandGitHub ba5c1cd70e fix: disable x frame options header on Hugging Face (#4558) 2024-12-04 00:06:19 +01:00
Max DeichmannandGitHub 19ea989b5a perf: add a user_id bloom filter (#4554)
* perf: add a user_id bloom filter

* push

* perf: add a user_id bloom filter
2024-12-03 21:28:52 +00:00
Max DeichmannandGitHub ec0bf37d0b chore: refactor tests async (#4552)
* chore: refactor tests

* chore: refactor tests
2024-12-03 20:03:57 +00:00
Max DeichmannandGitHub 3b4790270f fix: fix time to first token (#4549)
* fix: fix time to first token

* fix: fix time to first token

* fix: fix time to first token

* push

* fix: fix time to first token
2024-12-03 18:05:40 +00:00
Marc Klingen ca3b95312b chore: release v3.0.0-rc.3
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-03 18:53:59 +01:00
b9aaf05acb feat(auth): add KeyCloak authentication option (#2866)
---------

Co-authored-by: RTae <natthanan.bhu@doctorasa.co>
Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-12-03 18:46:09 +01:00
Marc KlingenandGitHub 004aee9f35 feat(ui): add "not found" error page for unknown traces and sessions (#4548)
* feat(ui): add "not found" page for unknown trace ids

* also apply to session
2024-12-03 16:56:03 +00:00
Steffen SchmitzandGitHub 0c9070f78f chore: migrate /metrics/daily API route to Clickhouse (#4546)
* chore: migrate /metrics/daily API route to Clickhouse

* chore: apply final
2024-12-03 16:35:09 +00:00
Marc KlingenandGitHub a91357a958 fix: remove LANGFUSE_CSP_DISABLE (did not work) and disable csp headers on HF Spaces (#4545) 2024-12-03 17:31:18 +01:00
Hassieb PakzadandGitHub 80050cee00 fix(media): add wait time before retries (#4547) 2024-12-03 17:24:43 +01:00
Steffen SchmitzandGitHub b533b1dccd chore: migrate traces all API route to Clickhouse (#4542)
* chore: migrate traces all API route to Clickhouse

* chore: lint

* chore: remove console logs

* chore: naming
2024-12-03 14:04:15 +00:00
Max DeichmannandGitHub 25657d11e4 perf: improve count query performance for scores public api (#4538)
feat
2024-12-03 10:38:41 +00:00
Max DeichmannandGitHub 3dd616a3dc perf: improve single trace public API performance (#4537)
feat
2024-12-03 10:11:04 +00:00
Steffen SchmitzandGitHub 995ec498f4 chore: include projectId in event processing error message (#4535) 2024-12-03 09:22:56 +00:00
Max DeichmannandGitHub 12d7b7f753 perf: improve scores API query performance (#4534)
* feat

* feat

* feat

* push

* feat

* feat

* feat
2024-12-03 09:17:55 +00:00
Marc KlingenandGitHub 2630327a86 chore: add LANGFUSE_CSP_DISABLE to .env.prod.example (#4533) 2024-12-03 01:23:15 +01:00
Marc KlingenandGitHub 4a1fa69af7 feat: optionally disable csp headers via LANGFUSE_CSP_DISABLE=true (#4529) 2024-12-03 01:04:23 +01:00
Max DeichmannandGitHub e53d101b49 fix: show dataset run if not run item exists yet (#4528)
feat
2024-12-02 21:46:54 +00:00
Marc KlingenandGitHub 39539300c1 fix: on cache schema change, delete cached api keys (#4527) 2024-12-02 20:36:12 +00:00
Hassieb PakzadandGitHub 59982db267 fix(io-ui): padding additional input (#4526) 2024-12-02 19:29:37 +01:00
Hassieb PakzadandGitHub fe4299d30d fix(IO-ui): render additional input in pretty view (#4524) 2024-12-02 18:58:25 +01:00
Marc KlingenandGitHub 304b290ab1 fix: no need to decode nextjs path variables, causes issues with special characters (#4522) 2024-12-02 17:09:06 +00:00
Hassieb PakzadandGitHub 97423aa95d fix(media): use db upserts instead of prisma (#4521) 2024-12-02 17:42:53 +01:00
Max DeichmannandGitHub 7aff8e0c26 fix: fix score source public api filter (#4520)
feat
2024-12-02 15:46:59 +00:00
Max DeichmannandGitHub 22761d55c3 chore: add logging for datasets router (#4519)
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-12-02 16:29:40 +01:00
steffen911 965cf1445d chore: release v3.0.0-rc.2 2024-12-02 16:28:36 +01:00
Marc KlingenandGitHub 922bbb0ce1 docs: add list of dependents to readme (#4518)
* docs: add list of dependents to readme

* push
2024-12-02 15:05:56 +00:00
Max DeichmannandGitHub d821423c6a perf: improve dataset run item performance (#4514) 2024-12-02 15:57:20 +01:00
Steffen SchmitzandGitHub 84b0b7f680 chore: remove backend cloud version checks (#4512)
* chore: remove backend cloud version checks

* chore: lint

* chore: adjust clickhouse healthcheck
2024-12-02 14:41:18 +00:00
Steffen SchmitzandGitHub fba7f4f0c3 build: cancel concurrent PR jobs on new commit (#4517)
* build: cancel concurrent PR jobs on new commit

* chore: adjust pattern

* chore: adjust pattern
2024-12-02 14:31:58 +00:00
Steffen SchmitzandGitHub a9405a2e8b chore: ignore latency for prompts-ui.servertest.ts (#4513) 2024-12-02 14:20:05 +00:00
Marc KlingenandGitHub 2cb3529707 fix(ui): disallow creation of projects if project name already exists in the org (#4516) 2024-12-02 14:12:46 +00:00
Marc KlingenandGitHub 2f13204836 fix(ui): overflow of session view on mobile (#4515) 2024-12-02 14:12:20 +00:00
Steffen SchmitzandGitHub ae2a42bf80 chore: move batch export s3 env variable names into new naming pattern (#4511)
* chore: move batch export s3 env variable names into new naming pattern

* chore: add prefixes correctly

* chore: overwrite batch export feature in CI
2024-12-02 13:49:15 +00:00
Marc KlingenandGitHub 039dee045b feat(cloud): update entitlements to account for limits, add self-hosted pro plan (#3913)
* wip

* push

* add migration

* render correct usage event type in settings

* render number of seats in settings

* wip

* add entitlement limits

* add new actionbutton

* migrate annotation queue create

* add to table date range selectors

* add limit to dashboard date range

* move migration

* fix usage preview in billing settings

* get seats from subscription instead of next invoice

* remove old console.log

* ad pro plan self-hosted

* fix

* readme

* rename / fixes

* push

* only alert on over 90% usage

* push

* push

* push

* push

* add new self host entitlements for v3

* drop batch-export entitlement

* push

* move logic to clickhouse

* push

* fix padding

* fix clickhouse logic
2024-12-02 13:39:02 +00:00
Max DeichmannandGitHub 9831bb8e44 fix: increase attempts for evals (#4487)
feat
2024-12-02 13:37:00 +00:00
Steffen SchmitzandGitHub c6e3a12b08 chore: apply v3 defaults to environment variables (#4498) 2024-12-02 13:50:44 +01:00
Marc KlingenandGitHub 9b01b562a8 fix(ui): account for prereleases in update check (#4503) 2024-11-29 18:34:12 +00:00
Hassieb Pakzad e5a0cec7d6 chore: add sha256 hash to media url span 2024-11-29 18:33:26 +01:00
Steffen SchmitzandGitHub 9375250c5a chore: fix typo in ingestion queue concurrency and adjust default (#4495) 2024-11-29 10:30:11 +00:00
Hassieb PakzadandGitHub 82e5f1a932 feat(playground+evals): add gemini and vertex support (#4485) 2024-11-29 10:07:33 +01:00
Max DeichmannandGitHub 64a62c9f6d perf: improve datasets view (#4486) 2024-11-28 21:21:05 +01:00
44d856747a feat(auth): add GitHub Enterprise Authentication Provider (#4463)
feat: Add GitHub Enterprise Authentication Provider

Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-11-28 01:43:43 +00:00
Marc Klingen e17bd364c9 chore: fix ci on fork prs 2024-11-28 02:30:08 +01:00
Marc KlingenandGitHub f38c756244 fix: do not authenticate with Docker Hub for pull request CI (#4477) 2024-11-28 02:20:29 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Marc Klingen
6c62f4b31a chore(deps): bump posthog-node from 3.6.3 to 4.3.1 (#4450)
* chore(deps): bump posthog-node from 3.6.3 to 4.3.1

Bumps [posthog-node](https://github.com/PostHog/posthog-js-lite/tree/HEAD/posthog-node) from 3.6.3 to 4.3.1.
- [Release notes](https://github.com/PostHog/posthog-js-lite/releases)
- [Changelog](https://github.com/PostHog/posthog-js-lite/blob/main/posthog-node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js-lite/commits/posthog-node-v4.3.1/posthog-node)

---
updated-dependencies:
- dependency-name: posthog-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix flush

* shutdown instead of shutdownasync

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc Klingen <git@marcklingen.com>
2024-11-28 00:07:51 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb686d45ee chore(deps): bump stripe from 16.8.0 to 17.4.0 (#4377)
Bumps [stripe](https://github.com/stripe/stripe-node) from 16.8.0 to 17.4.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v16.8.0...v17.4.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-27 23:47:54 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1901c8cb39 chore(deps): bump lucide-react from 0.447.0 to 0.462.0 (#4475)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.447.0 to 0.462.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.462.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-27 23:47:05 +00:00
Marc Klingen 66d925ee1f chore: do not run codeql on merge queue 2024-11-28 01:05:07 +01:00
Marc Klingen 1b06385111 chore: run ci on merge groups 2024-11-28 00:23:08 +01:00
Hassieb PakzadandGitHub 6e290b9ad4 fix(media): increase retries (#4474) 2024-11-27 22:57:53 +00:00
Max DeichmannandGitHub 8b8afff705 fix: fix generations search in clickhouse (#4472) 2024-11-27 23:20:21 +01:00
Max DeichmannandGitHub 89fcf27f84 chore: add logs for media API (#4471) 2024-11-27 22:46:44 +01:00
Max DeichmannandGitHub 6717b16679 perf: add improved default performance to traces table (#4469)
* feat

* feat
2024-11-27 21:07:29 +00:00
Cole WinstanleyandGitHub 98e29cb512 fix(auth): add nonce check for Cognito NextAuth provider (#4401) 2024-11-27 17:59:31 +01:00
Marc KlingenandGitHub c7b7a784d7 feat(ui): add more explicit confirmation prompt before deleting a dataset (#4424) 2024-11-27 16:51:44 +00:00
Marc KlingenandGitHub e778d04523 perf: single clickhouse query for cloud usage job across all orgs/projects (#4453) 2024-11-27 17:39:31 +01:00
Hassieb PakzadandGitHub 2a2f50e2d9 feat(media): broaden content type support (#4442)
* feat(media): broaden content type support

* fix file extensions

* add media attachment view in pretty mode
2024-11-27 17:19:54 +01:00
Max DeichmannandGitHub 9d9820dacc chore: remove log line (#4466) 2024-11-27 15:54:39 +01:00
Max DeichmannandGitHub 6e4065eff3 perf: extract traces table metrics from clickhouse traces.all query (#4460) 2024-11-27 14:24:15 +00:00
Marc KlingenandGitHub 993e2a8323 fix(ui): trace timeline peek drawer should not be full-width (#4462) 2024-11-27 14:04:45 +00:00
Steffen SchmitzandGitHub f0c117b35b chore: expand error logging for queue worker errors (#4457) 2024-11-27 12:29:59 +00:00
Max DeichmannandGitHub b7d353a821 perf: increase performance for traces default table (#4459) 2024-11-27 11:36:14 +00:00
Hassieb PakzadandGitHub 1bdc6a4068 fix(media): increase isolationLevel on concurrent upserts (#4458) 2024-11-27 12:23:08 +01:00
Marc KlingenandGitHub 851de49789 perf: prevent duplicated parallel executions of cloud metering job (#4455) 2024-11-27 01:27:07 +00:00
Max DeichmannandGitHub 202a9b715d perf: improve clickhouse traces table perfromance (#4451) 2024-11-26 23:25:45 +00:00
Marc KlingenandGitHub 5fae362b29 refactor(cloud): move usage metering job to clickhouse (#4448) 2024-11-26 21:27:04 +00:00
Max DeichmannandGitHub 38e5c3fac7 Max/lfe 2988 adjust generations table default again (#4446) 2024-11-26 21:01:10 +00:00
Marc KlingenandGitHub 3d6d94fb16 chore(cloud): improve logs of usage metering job (#4447)
chore: improve logs of usage metering job
2024-11-26 20:27:14 +00:00
Marc KlingenandGitHub cb7230ac41 fix: override score name when config is set (#4445) 2024-11-26 20:08:36 +00:00
Steffen SchmitzandGitHub 44ba6eaddf chore: restrict trace.name length to 1000 characters (#4441) 2024-11-26 19:47:54 +01:00
Max DeichmannandGitHub e566dfa5a9 feat: improve generations table default query time (#4440) 2024-11-26 18:12:13 +00:00
Steffen SchmitzandGitHub f9c3f60d49 chore: make trace deletion asynchronous (#4398) 2024-11-26 16:26:31 +00:00
Steffen SchmitzandGitHub aeb56ecde7 chore: explicitly cast names for traces table (#4433) 2024-11-26 17:15:32 +01:00
Marc KlingenandGitHub 9ac84fbd61 fix: do not log stack trace in trpc checkupdate when no internet access (#4436) 2024-11-26 16:33:33 +01:00
Hassieb PakzadandGitHub fe9af426dc fix(playground): unique prompt names in save to prompt (#4437) 2024-11-26 16:22:44 +01:00
Marc KlingenandGitHub 583e90321e fix(auth): improve name and password verification (#4434)
* fix(auth): improve name and password verification

* password complexity
* no url in name

* e2e test
2024-11-26 13:28:43 +00:00
Hassieb Pakzad e52530351a chore: update v3preview docker compose to main tagged images 2024-11-26 13:49:28 +01:00
Hassieb Pakzad 8a52c0b888 chore: update v3preview docker compose to 3 tagged images 2024-11-26 13:32:16 +01:00
Hassieb Pakzad d02c9b4902 chore: release v3.0.0-rc.1
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-11-26 12:20:20 +01:00
Hassieb Pakzad 014c3d9dad chore: do not tag v3 tags as latest image 2024-11-26 12:17:51 +01:00
Max DeichmannandGitHub 1d3c74435b fix: change the default of the migration script (#4419) 2024-11-26 09:48:37 +00:00
Steffen SchmitzandGitHub 6b48d7771c build: adjust docker actions to handle v2 branch (#4416)
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode) (push) Waiting to run
CI/CD / tests-worker (node20, pg12, mode-azure) (push) Waiting to run
CI/CD / tests-worker (node20, pg15, mode-azure) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
2024-11-26 09:59:57 +01:00
Max DeichmannandGitHub cd0d39b3c2 fix: fix bullmq api (#4425) 2024-11-25 23:18:16 +00:00
Max DeichmannandGitHub 8fcbcdd29d fix: order datasets API correctly (#4423) 2024-11-25 22:09:49 +00:00
Max DeichmannandGitHub e24f65c51d feat: add all queues to get api for bullmq api (#4422) 2024-11-25 21:53:27 +00:00
Marc KlingenandGitHub 38e6464219 chore: improve logging of cloud metering queue (#4420) 2024-11-25 21:22:13 +00:00
Steffen SchmitzandGitHub 86f5c885a1 chore: skip string parsing for metadata string values (#4415) 2024-11-25 18:04:49 +01:00
Hassieb PakzadandGitHub c0ebd06c0c feat(evaluators): add updates (#4360) 2024-11-25 16:51:46 +01:00
Steffen SchmitzandGitHub 0ce5ddcd00 feat: add azure storage provider support (#4386) 2024-11-25 14:57:40 +00:00
Steffen SchmitzandGitHub 7539af3bd9 chore: overwrite numerical type for second filters on observations table (#4412) 2024-11-25 14:39:04 +00:00
Steffen SchmitzandGitHub 60f9147873 fix: use larger numeric type for latency filters (#4385) 2024-11-25 13:53:27 +00:00
Steffen SchmitzandGitHub a594c142e0 chore: use clickhouse in evaluations queues (#4319) 2024-11-25 13:42:17 +00:00
Max DeichmannandGitHub 18905a9872 feat: add all scores to run items UI (#4409) 2024-11-25 14:31:11 +01:00
Hassieb PakzadandGitHub 27f5dd837d chore(costs): add chatgpt-4o-latest prices (#4407) 2024-11-25 13:55:58 +01:00
Max DeichmannandGitHub a921f9db94 feat: exclude env defined operations from clickhouse (#4405) 2024-11-25 12:56:12 +01:00
Marc Klingen b3cd940c73 chore(ui): remove ph badge (#4403) 2024-11-25 10:41:33 +01:00
Max DeichmannandGitHub f91cf1ca65 feat: refactor clickhouse filter builder for SDK APIs (#4402) 2024-11-24 22:45:29 +00:00
Max DeichmannandGitHub c438e89bd0 feat: support scores list endpoint from clickhouse (#4400) 2024-11-24 21:08:44 +00:00
Max DeichmannandGitHub 9b1746b7ed feat: fetch observations via api from clickhouse (#4395) 2024-11-24 09:48:17 +00:00
Steffen SchmitzandGitHub 0611a72c40 fix: allow special characters in openai tokenization (#4396)
* fix: allow special characters in openai tokenization

* chore: update test
2024-11-24 05:26:45 +00:00
Max DeichmannandGitHub 0ba66526e9 fix: do not use memory tables in clickhouse (#4394) 2024-11-23 18:21:30 +01:00
Max DeichmannandGitHub 425202dd1e fix: correctly retrieve scores for datasets (#4387) 2024-11-22 17:20:48 +00:00
Hassieb PakzadandGitHub 882ad83aad chore: upgrade langfuse-langchain (#4384) 2024-11-22 12:12:00 +01:00
Max DeichmannandGitHub 9b12783d08 fix: fix dataset run table to show all scores (#4383) 2024-11-22 10:44:23 +00:00
Marc KlingenandGitHub c6817ae32f chore: add Product Hunt Launch note (#4364) 2024-11-22 08:50:23 +01:00
marliessophieandGitHub 42711a8cc9 fix: don't show error message to user if trace is not yet available for dataset (#4382) 2024-11-22 08:27:54 +01:00
Marc KlingenandGitHub 0344ab2cf9 fix: experimentation should not use structured output (#4379) 2024-11-22 04:57:47 +01:00
Marc KlingenandGitHub df0d12af43 chore: rename default experiment tag langfuse-prompt-experiment (#4378) 2024-11-22 01:01:40 +00:00
marliessophieandGitHub d058ba8861 docs: add link to experimentation docs (#4371) 2024-11-21 22:42:36 +00:00
marliessophieandGitHub d1309905d0 chore: create dataset run items in order of dataset items timestamp (#4370)
* chore: create dataset run items in order of dataset items timestamp
2024-11-21 22:36:47 +01:00
Max DeichmannandGitHub ca6ac3912f fix: fix dataset run item api to always return traceid (#4367) 2024-11-21 20:25:30 +00:00
Max DeichmannandGitHub 9eabc32681 fix: return i/o from single object APIs (#4368) 2024-11-21 20:14:21 +00:00
marliessophieandGitHub 8798e4a499 feat(experiments): trigger experiments via UI (#4333) 2024-11-21 20:00:07 +01:00
Max Deichmann ea8b2da37c chore: release v2.92.0
CI/CD / lint (push) Waiting to run
CI/CD / test-docker-build (push) Waiting to run
CI/CD / tests-web-sync (node20, pg12) (push) Waiting to run
CI/CD / tests-web-sync (node20, pg15) (push) Waiting to run
CI/CD / tests-web-async (node20, pg12) (push) Waiting to run
CI/CD / tests-web-async (node20, pg15) (push) Waiting to run
CI/CD / tests-worker (node20, pg12) (push) Waiting to run
CI/CD / tests-worker (node20, pg15) (push) Waiting to run
CI/CD / e2e-tests (push) Waiting to run
CI/CD / e2e-server-tests (push) Waiting to run
CI/CD / all-ci-passed (push) Blocked by required conditions
CI/CD / push-docker-image (push) Blocked by required conditions
release.yml / release (push) Waiting to run
2024-11-21 18:44:30 +01:00
Max DeichmannandGitHub b7f7d10fea fix: fix dataset item api for self-hosters (#4366) 2024-11-21 17:43:48 +00:00
Marc KlingenandGitHub 0279c0f48e docs: add notification for lw 2 - day 4 (#4362) 2024-11-21 17:16:58 +01:00
Max DeichmannandGitHub c29cf3855f feat: SDK API for observations by id (#4295) 2024-11-21 15:28:34 +00:00
Max DeichmannandGitHub c73901369c fix: fix cost filters for traces table (#4325) 2024-11-21 14:21:01 +00:00
Hassieb PakzadandGitHub 0e70f03fc3 fix(media): lookup env var by exact string (#4356) 2024-11-21 10:48:33 +01:00
Max DeichmannandGitHub 74d1f7f717 feat: fetch single trace via API from clickhouse (#4344) 2024-11-21 10:38:24 +01:00
Marc KlingenandGitHub dfcf67184d docs: add lw2-3 info for multi-modal (#4351) 2024-11-20 23:15:19 +01:00
Max DeichmannandGitHub ce641d1e2b fix: do not fail on invalid prompt templates for evals (#4349) 2024-11-20 19:45:12 +00:00
Max DeichmannandGitHub 166b84a81a fix: do not use temp tables for dataset runs (#4347)
fix
2024-11-20 17:56:55 +00:00
Max DeichmannandGitHub f4097a1ae5 fix: dataset item api fix for large dataset runs (#4313) 2024-11-20 16:33:51 +00:00
Max DeichmannandGitHub 8374e889cf security: upgrade cross-spawn (#4339) 2024-11-20 13:32:19 +00:00
Max DeichmannandGitHub 62deaf7e00 sec: upgrade sentry (#4338) 2024-11-20 13:10:54 +00:00
Max DeichmannandGitHub a4cd68b1ad security: upgrade json path (#4335) 2024-11-20 13:32:01 +01:00
marliessophieandGitHub dde28f21fa chore: don't create traces in evaluation service (#4334) 2024-11-20 13:18:56 +01:00
Hassieb PakzadandGitHub a4c040b314 feat(core): add server side tracing of LLM invocations (#4317) 2024-11-20 12:00:31 +01:00
Max DeichmannandGitHub 87995625ca fix: correct traces pagination (#4329) 2024-11-20 02:31:34 +01:00
Marc KlingenandGitHub e717854909 docs: add launch week day 2 note (#4328) 2024-11-20 00:20:47 +01:00
Max DeichmannandGitHub 9e37f2cb17 feat: add more bullmq helpers (#4324) 2024-11-19 21:51:51 +00:00
Max DeichmannandGitHub fc08db08f3 fix: fix bullmq retry (#4323) 2024-11-19 21:24:55 +01:00
Max DeichmannandGitHub 270bc793ba feat: add service endpoint for failed bull events (#4321) 2024-11-19 21:18:40 +01:00
Max DeichmannandGitHub ced2d339ee fix: increase dataset item delay (#4318) 2024-11-19 17:46:49 +01:00
382 changed files with 23741 additions and 10313 deletions
+84
View File
@@ -0,0 +1,84 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
# Clickhouse
CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# Langfuse Cloud Environment
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="DEV"
# Langfuse experimental features
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
# Salt for API key hashing
SALT="salt"
# Email
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# DON'T PANIC: The Azurite Secrets are well-known and meant to be hard-coded
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_BATCH_EXPORT_REGION=auto
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=devstoreaccount1
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://localhost:10000/devstoreaccount1
## Necessary for minio compatibility
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
LANGFUSE_USE_AZURE_BLOB=true
# Set during docker build of application
# Used to disable environment verification at build time
# DOCKER_BUILD=1
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
+11 -17
View File
@@ -11,7 +11,7 @@ CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
@@ -34,17 +34,18 @@ SALT="salt"
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# S3 storage
S3_ENDPOINT=http://localhost:9090
S3_ACCESS_KEY_ID=minio
S3_SECRET_ACCESS_KEY=miniosecret
S3_BUCKET_NAME=langfuse
S3_REGION=us-east-1
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
S3_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# # S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
# S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
@@ -56,7 +57,6 @@ LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
@@ -79,9 +79,3 @@ ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
LANGFUSE_READ_FROM_POSTGRES_ONLY=false
LANGFUSE_RETURN_FROM_CLICKHOUSE=true
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=true
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=true
+16 -11
View File
@@ -11,7 +11,7 @@ CLICKHOUSE_MIGRATION_URL="clickhouse://localhost:9000"
CLICKHOUSE_URL="http://localhost:8123"
CLICKHOUSE_USER="clickhouse"
CLICKHOUSE_PASSWORD="clickhouse"
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED="true"
CLICKHOUSE_CLUSTER_ENABLED="false"
# Next Auth
# You can generate a new secret on the command line with:
@@ -34,17 +34,18 @@ SALT="salt"
EMAIL_FROM_ADDRESS="" # Defines the email address to use as the from address.
SMTP_CONNECTION_URL="" # Defines the connection url for smtp server.
# S3 storage
S3_ENDPOINT=http://localhost:9090
S3_ACCESS_KEY_ID=minio
S3_SECRET_ACCESS_KEY=miniosecret
S3_BUCKET_NAME=langfuse
S3_REGION=us-east-1
# S3 Batch Exports
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
LANGFUSE_S3_BATCH_EXPORT_REGION=us-east-1
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://localhost:9090
## Necessary for minio compatibility
S3_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
# # S3 Media Upload LOCAL
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED=true
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
@@ -56,7 +57,6 @@ LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
# S3 Event Bucket Upload
## Set to true to test uploading all events to S3
LANGFUSE_S3_EVENT_UPLOAD_ENABLED=true
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
@@ -78,4 +78,9 @@ REDIS_AUTH="myredissecret"
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# speeds up local development by not executing init scripts on server startup
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
LANGFUSE_READ_FROM_POSTGRES_ONLY=true
LANGFUSE_RETURN_FROM_CLICKHOUSE=false
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE=false
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY=false
-22
View File
@@ -1,22 +0,0 @@
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
# Next Auth
NEXTAUTH_SECRET="secret"
NEXTAUTH_URL="http://localhost:3000"
# feature flag to enable experimental features locally
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
SALT="salt"
# Redis
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_AUTH="myredissecret"
# openssl rand -hex 32 used only here
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+16 -9
View File
@@ -67,6 +67,10 @@ OTEL_SERVICE_NAME="langfuse"
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
# AUTH_GITHUB_ENTERPRISE_CLIENT_ID=
# AUTH_GITHUB_ENTERPRISE_CLIENT_SECRET=
# AUTH_GITHUB_ENTERPRISE_BASE_URL=
# AUTH_GITHUB_ENTERPRISE_ALLOW_ACCOUNT_LINKING=false
# AUTH_GITLAB_CLIENT_ID=
# AUTH_GITLAB_CLIENT_SECRET=
# AUTH_GITLAB_ALLOW_ACCOUNT_LINKING=false
@@ -87,6 +91,10 @@ OTEL_SERVICE_NAME="langfuse"
# AUTH_COGNITO_CLIENT_SECRET=
# AUTH_COGNITO_ISSUER=
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
# AUTH_KEYCLOAK_CLIENT_ID=
# AUTH_KEYCLOAK_CLIENT_SECRET=
# AUTH_KEYCLOAK_ISSUER=
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
# AUTH_CUSTOM_CLIENT_ID=
# AUTH_CUSTOM_CLIENT_SECRET=
# AUTH_CUSTOM_ISSUER=
@@ -100,16 +108,16 @@ OTEL_SERVICE_NAME="langfuse"
# Defines the connection url for smtp server.
# SMTP_CONNECTION_URL=
# S3 storage, optional, used for exports from the UI
# S3_ENDPOINT=
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_BUCKET_NAME=
# S3_REGION=
# BATCH_EXPORT_DOWNLOAD_LINK_EXPIRATION_HOURS=
# S3 Batch Exports
# LANGFUSE_S3_BATCH_EXPORT_ENABLED=
# LANGFUSE_S3_BATCH_EXPORT_BUCKET=
# LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=
# LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=
# LANGFUSE_S3_BATCH_EXPORT_REGION=
# LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=
# LANGFUSE_S3_BATCH_EXPORT_PREFIX=
# S3 storage for events, optional, used to persist all incoming events
# LANGFUSE_S3_EVENT_UPLOAD_ENABLED="true"
# LANGFUSE_S3_EVENT_UPLOAD_BUCKET=
# Optional prefix to be used within the bucket. Must end with `/` if set
# LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
@@ -242,7 +250,6 @@ OTEL_SERVICE_NAME="langfuse"
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
# LANGFUSE_INGESTION_CLICKHOUSE_MAX_ATTEMPTS=
# LANGFUSE_LEGACY_INGESTION_WORKER_CONCURRENCY=
# LANGFUSE_ASYNC_INGESTION_PROCESSING="true"
# QUEUE_CONSUMER_LEGACY_INGESTION_QUEUE_IS_ENABLED="true"
## END Langfuse V3 Ingestion
@@ -58,7 +58,6 @@ jobs:
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
.
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
- name: Render AWS ECS Task Definition
id: render-task-definition
uses: aws-actions/amazon-ecs-render-task-definition@v1
+1
View File
@@ -6,6 +6,7 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
permissions:
contents: read
+52 -12
View File
@@ -10,7 +10,11 @@ on:
merge_group:
pull_request:
branches:
- "*"
- "**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
lint:
@@ -41,6 +45,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -89,6 +94,7 @@ jobs:
with:
version: 9.5.0
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -105,7 +111,8 @@ jobs:
- name: Load default env
run: |
cp .env.dev.example .env
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.legacy.example > .env
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.legacy.example > .env
echo "LANGFUSE_POSTGRES_INGESTION_ENABLED=true" >> .env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
@@ -133,15 +140,18 @@ jobs:
LANGFUSE_INIT_USER_PASSWORD: "password"
- name: run test-sync
run: pnpm --filter=web run test-sync
- name: run test-client
run: pnpm --filter=web run test-client
tests-web-async:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
name: tests-web-async (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -157,6 +167,7 @@ jobs:
with:
version: 9.5.0
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -172,11 +183,11 @@ jobs:
pnpm install
- name: Load default env
run: |
cp .env.dev.example .env
grep -v -e '^S3_BUCKET_NAME=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev.example > .env
cp .env.dev${{ matrix.blob-provider }}.example .env
grep -v -e '^LANGFUSE_S3_BATCH_EXPORT_ENABLED=' -e '^REDIS_HOST=' -e '^NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT=' .env.dev${{ matrix.blob-provider }}.example > .env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
env:
@@ -205,11 +216,12 @@ jobs:
tests-worker:
timeout-minutes: 20
runs-on: ubuntu-latest
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }})
name: tests-worker (node${{ matrix.node-version }}, pg${{ matrix.postgres-version }}, mode${{ matrix.blob-provider }})
strategy:
matrix:
node-version: [20]
postgres-version: [12, 15]
blob-provider: ["", "-azure"]
steps:
- name: Set Swap Space
uses: pierotofy/set-swap-space@master
@@ -220,6 +232,7 @@ jobs:
with:
version: 9.5.0
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -240,12 +253,12 @@ jobs:
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
cp .env.dev.example web/.env
cp .env.dev.example worker/.env
cp .env.dev${{ matrix.blob-provider }}.example .env
cp .env.dev${{ matrix.blob-provider }}.example web/.env
cp .env.dev${{ matrix.blob-provider }}.example worker/.env
- name: Run + migrate
run: |
docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.dev${{ matrix.blob-provider }}.yml up -d
sleep 5 # Wait for PostgreSQL to accept connections
docker compose ps
- name: Ensure no unhealthy status
@@ -279,6 +292,7 @@ jobs:
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -311,6 +325,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
@@ -326,10 +341,14 @@ jobs:
- name: install dependencies
run: |
pnpm install
- name: Install golang-migrate for Clickhouse migrations
run: |
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
sudo mv migrate /usr/bin/migrate
which migrate
- name: Load default env
run: |
cp .env.dev.example .env
echo "LANGFUSE_ASYNC_INGESTION_PROCESSING=true" >> .env
echo "LANGFUSE_CACHE_API_KEY_ENABLED=true" >> .env
echo "LANGFUSE_CACHE_PROMPT_ENABLED=true" >> .env
- name: Run + migrate
@@ -337,14 +356,29 @@ jobs:
docker compose -f docker-compose.dev.yml up -d
docker compose ps
sleep 5 # Wait for PostgreSQL to accept connections
- name: Ensure Docker dependencies are healthy
run: |
if docker-compose ps | grep "(unhealthy)"; then
echo "One or more services are unhealthy"
exit 1
else
echo "All services are healthy"
fi
- name: Seed DB
run: |
pnpm run db:migrate
pnpm --filter=shared run ch:up
pnpm run db:seed:examples
- name: Build
run: pnpm run build
- name: Run server
run: (pnpm run start&)
- name: Check worker health
run: |
timeout 10 bash -c 'until curl -f http://localhost:3030/api/health; do sleep 2; done'
- name: Check server health
run: |
timeout 10 bash -c 'until curl -f http://localhost:3000/api/public/health; do sleep 2; done'
- name: Run e2e tests
run: pnpm --filter=web run test:e2e:server
@@ -418,6 +452,8 @@ jobs:
images: |
ghcr.io/langfuse/langfuse # GitHub
langfuse/langfuse # Docker Hub
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -425,6 +461,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
- name: Build and push Docker image (web)
uses: docker/build-push-action@v4
with:
@@ -443,6 +480,8 @@ jobs:
images: |
ghcr.io/langfuse/langfuse-worker # GitHub
langfuse/langfuse-worker # Docker Hub
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -450,6 +489,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') }}
- name: Build and push Docker image (worker)
uses: docker/build-push-action@v4
with:
+1 -1
View File
@@ -3,7 +3,7 @@ on:
push:
# Pattern matched against refs/tags
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # Semantic version tags
- "v3.[0-9]+.[0-9]+" # Semantic version tags
jobs:
release:
+8 -3
View File
@@ -6,6 +6,11 @@ on:
push:
branches:
- main
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
snyk:
@@ -15,13 +20,13 @@ jobs:
- name: Build a Docker image
run: docker compose -f docker-compose.build.yml up -d
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-server)
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-web)
continue-on-error: true
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse-server
image: langfuse-langfuse-web
args: --file=web/Dockerfile
- name: Upload result to GitHub Code Scanning
@@ -36,7 +41,7 @@ jobs:
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: langfuse-worker
image: langfuse-langfuse-worker
args: --file=worker/Dockerfile
- name: Upload result to GitHub Code Scanning
+1 -1
View File
@@ -37,7 +37,7 @@ yarn-error.log*
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env*
!.env.dev.example
!.env.local.example
!.env.dev-azure.example
!.env.prod.example
!.env.dev.legacy.example
+2 -2
View File
@@ -2,8 +2,8 @@ Copyright (c) 2023--2024 Langfuse GmbH
Portions of this software are licensed as follows:
- All content that resides under the "ee/" and/or "web/src/ee" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Finto Technologies Software are licensed under the original license provided by the owner of the applicable component.
- All content that resides under the "ee/", "web/src/ee/", and/or "worker/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Langfuse Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy
+37
View File
@@ -191,3 +191,40 @@ You can opt-out by setting `TELEMETRY_ENABLED=false`.
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=langfuse/langfuse&type=Date" />
</picture>
</a>
### Open Source Projects Using Langfuse
Top open-source Python projects that use Langfuse, ranked by stars ([Source](https://github.com/langfuse/langfuse-docs/blob/main/components-mdx/dependents/python.md)):
| Repository | Stars |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----: |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/127165244?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langgenius](https://github.com/langgenius) / [dify](https://github.com/langgenius/dify) | 53464 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [open-webui](https://github.com/open-webui/open-webui) | 49387 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/130722866?s=40&v=4" width="20" height="20" alt=""> &nbsp; [run-llama](https://github.com/run-llama) / [llama_index](https://github.com/run-llama/llama_index) | 37087 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85702467?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langflow-ai](https://github.com/langflow-ai) / [langflow](https://github.com/langflow-ai/langflow) | 36690 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/31035808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [mindsdb](https://github.com/mindsdb) / [mindsdb](https://github.com/mindsdb/mindsdb) | 26871 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> &nbsp; [PostHog](https://github.com/PostHog) / [posthog](https://github.com/PostHog/posthog) | 22348 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/121462774?s=40&v=4" width="20" height="20" alt=""> &nbsp; [BerriAI](https://github.com/BerriAI) / [litellm](https://github.com/BerriAI/litellm) | 14508 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/78410652?s=40&v=4" width="20" height="20" alt=""> &nbsp; [GreyDGL](https://github.com/GreyDGL) / [PentestGPT](https://github.com/GreyDGL/PentestGPT) | 7319 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/152537519?s=40&v=4" width="20" height="20" alt=""> &nbsp; [superagent-ai](https://github.com/superagent-ai) / [superagent](https://github.com/superagent-ai/superagent) | 5352 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/7250217?s=40&v=4" width="20" height="20" alt=""> &nbsp; [Canner](https://github.com/Canner) / [WrenAI](https://github.com/Canner/WrenAI) | 2084 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/11855343?s=40&v=4" width="20" height="20" alt=""> &nbsp; [pingcap](https://github.com/pingcap) / [autoflow](https://github.com/pingcap/autoflow) | 1936 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/85268109?s=40&v=4" width="20" height="20" alt=""> &nbsp; [MLSysOps](https://github.com/MLSysOps) / [MLE-agent](https://github.com/MLSysOps/MLE-agent) | 1140 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/158137808?s=40&v=4" width="20" height="20" alt=""> &nbsp; [open-webui](https://github.com/open-webui) / [pipelines](https://github.com/open-webui/pipelines) | 1044 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/125468716?s=40&v=4" width="20" height="20" alt=""> &nbsp; [topoteretes](https://github.com/topoteretes) / [cognee](https://github.com/topoteretes/cognee) | 945 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/188657705?s=40&v=4" width="20" height="20" alt=""> &nbsp; [bRAGAI](https://github.com/bRAGAI) / [bRAG-langchain](https://github.com/bRAGAI/bRAG-langchain) | 770 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/169500408?s=40&v=4" width="20" height="20" alt=""> &nbsp; [opslane](https://github.com/opslane) / [opslane](https://github.com/opslane/opslane) | 675 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/151867818?s=40&v=4" width="20" height="20" alt=""> &nbsp; [dynamiq-ai](https://github.com/dynamiq-ai) / [dynamiq](https://github.com/dynamiq-ai/dynamiq) | 583 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/48585267?s=40&v=4" width="20" height="20" alt=""> &nbsp; [theopenconversationkit](https://github.com/theopenconversationkit) / [tock](https://github.com/theopenconversationkit/tock) | 505 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/20493493?s=40&v=4" width="20" height="20" alt=""> &nbsp; [andysingal](https://github.com/andysingal) / [llm-course](https://github.com/andysingal/llm-course) | 383 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/132396805?s=40&v=4" width="20" height="20" alt=""> &nbsp; [phospho-app](https://github.com/phospho-app) / [phospho](https://github.com/phospho-app/phospho) | 379 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/178644984?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sentient-engineering](https://github.com/sentient-engineering) / [agent-q](https://github.com/sentient-engineering/agent-q) | 357 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/168552753?s=40&v=4" width="20" height="20" alt=""> &nbsp; [sql-agi](https://github.com/sql-agi) / [DB-GPT](https://github.com/sql-agi/DB-GPT) | 324 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/60330232?s=40&v=4" width="20" height="20" alt=""> &nbsp; [PostHog](https://github.com/PostHog) / [posthog-foss](https://github.com/PostHog/posthog-foss) | 300 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/609489?s=40&v=4" width="20" height="20" alt=""> &nbsp; [aorwall](https://github.com/aorwall) / [moatless-tools](https://github.com/aorwall/moatless-tools) | 278 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/185116535?s=40&v=4" width="20" height="20" alt=""> &nbsp; [block](https://github.com/block) / [goose](https://github.com/block/goose) | 237 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/66303003?s=40&v=4" width="20" height="20" alt=""> &nbsp; [RobotecAI](https://github.com/RobotecAI) / [rai](https://github.com/RobotecAI/rai) | 164 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/171735272?s=40&v=4" width="20" height="20" alt=""> &nbsp; [8090-inc](https://github.com/8090-inc) / [xrx-sample-apps](https://github.com/8090-inc/xrx-sample-apps) | 133 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/51827949?s=40&v=4" width="20" height="20" alt=""> &nbsp; [deepset-ai](https://github.com/deepset-ai) / [haystack-core-integrations](https://github.com/deepset-ai/haystack-core-integrations) | 122 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/134601687?s=40&v=4" width="20" height="20" alt=""> &nbsp; [langfuse](https://github.com/langfuse) / [langfuse-python](https://github.com/langfuse/langfuse-python) | 108 |
| <img class="avatar mr-2" src="https://avatars.githubusercontent.com/u/42655961?s=40&v=4" width="20" height="20" alt=""> &nbsp; [zozoheir](https://github.com/zozoheir) / [tinyllm](https://github.com/zozoheir/tinyllm) | 95 |
+114 -44
View File
@@ -1,34 +1,53 @@
version: "3.5"
services:
server:
langfuse-web:
build:
dockerfile: ./web/Dockerfile
context: .
args:
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
depends_on:
- db
- redis
depends_on: &langfuse-depends-on
postgres:
condition: service_healthy
minio:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
environment: &langfuse-web-env
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
NEXTAUTH_SECRET: mysecret
SALT: mysalt
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # generate via `openssl rand -hex 32`
NEXTAUTH_URL: http://localhost:3000
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true}
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-}
LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-}
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse}
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-us-east-1}
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/public/health"]
@@ -36,26 +55,17 @@ services:
timeout: 10s
retries: 3
worker:
langfuse-worker:
build:
dockerfile: ./worker/Dockerfile
context: .
args:
- NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${NEXT_PUBLIC_LANGFUSE_CLOUD_REGION}
depends_on:
- db
- redis
depends_on: *langfuse-depends-on
ports:
- "3030:3030"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
- PORT=${PORT:-3030}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_AUTH=${REDIS_AUTH:-myredissecret}
<<: *langfuse-web-env
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3030/api/health"]
@@ -63,26 +73,86 @@ services:
timeout: 10s
retries: 3
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
interval: 5s
timeout: 5s
retries: 10
start_period: 1s
minio:
image: minio/minio
container_name: 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'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
ports:
- "9090:9000"
- "9091:9001"
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
redis:
image: redis:7.2.4
image: redis:7
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
restart: always
ports:
- 6379:6379
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 10s
retries: 10
db:
image: postgres
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- database_data:/var/lib/postgresql/data
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
database_data:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_minio_data:
driver: local
+63
View File
@@ -0,0 +1,63 @@
services:
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
depends_on:
- postgres
azurite:
image: mcr.microsoft.com/azure-storage/azurite
container_name: azurite
command: azurite-blob --blobHost 0.0.0.0
ports:
- "10000:10000"
volumes:
- langfuse_azurite_data:/data
redis:
image: redis:7.2.4
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
ports:
- 6379:6379
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
command: ["postgres", "-c", "log_statement=all"]
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
ports:
- 5432:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_azurite_data:
driver: local
-145
View File
@@ -1,145 +0,0 @@
services:
langfuse-worker:
image: langfuse/langfuse-worker:latest
depends_on: &langfuse-depends-on
postgres:
condition: service_healthy
minio:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "3030:3030"
environment: &langfuse-worker-env
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
SALT: "mysalt"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # generate via `openssl rand -hex 32`
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true}
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-true}
LANGFUSE_ASYNC_INGESTION_PROCESSING: ${LANGFUSE_ASYNC_INGESTION_PROCESSING:-true}
LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING: ${LANGFUSE_ASYNC_CLICKHOUSE_INGESTION_PROCESSING:-true}
LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE: ${LANGFUSE_READ_DASHBOARDS_FROM_CLICKHOUSE:-true}
LANGFUSE_READ_FROM_POSTGRES_ONLY: ${LANGFUSE_READ_FROM_POSTGRES_ONLY:-false}
LANGFUSE_RETURN_FROM_CLICKHOUSE: ${LANGFUSE_RETURN_FROM_CLICKHOUSE:-true}
CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse}
CLICKHOUSE_MIGRATION_CLUSTER_DISABLED: ${CLICKHOUSE_MIGRATION_CLUSTER_DISABLED:-true}
LANGFUSE_S3_EVENT_UPLOAD_ENABLED: ${LANGFUSE_S3_EVENT_UPLOAD_ENABLED:-true}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-us-east-1}
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/}
LANGFUSE_S3_MEDIA_UPLOAD_ENABLED: ${LANGFUSE_S3_MEDIA_UPLOAD_ENABLED:-true}
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-us-east-1}
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
langfuse-web:
image: langfuse/langfuse:latest
depends_on: *langfuse-depends-on
ports:
- "3000:3000"
environment:
<<: *langfuse-worker-env
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: mysecret
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-}
LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-}
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
clickhouse:
image: clickhouse/clickhouse-server
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
depends_on:
- postgres
minio:
image: minio/minio
container_name: 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'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
ports:
- "9090:9000"
- "9091:9001"
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
redis:
image: redis:7
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
ports:
- 6379:6379
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 10s
retries: 10
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_minio_data:
driver: local
+133 -27
View File
@@ -1,31 +1,131 @@
services:
langfuse-server:
image: langfuse/langfuse:2
depends_on:
db:
langfuse-worker:
image: langfuse/langfuse-worker:3
restart: always
depends_on: &langfuse-depends-on
postgres:
condition: service_healthy
minio:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
ports:
- "3030:3030"
environment: &langfuse-worker-env
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
SALT: "mysalt"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" # generate via `openssl rand -hex 32`
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true}
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-true}
CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse}
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto}
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/}
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-auto}
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-miniosecret}
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000}
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_AUTH: ${REDIS_AUTH:-myredissecret}
langfuse-web:
image: langfuse/langfuse:3
restart: always
depends_on: *langfuse-depends-on
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_SECRET=mysecret
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # generate via `openssl rand -hex 32`
- NEXTAUTH_URL=http://localhost:3000
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
- LANGFUSE_INIT_ORG_ID=${LANGFUSE_INIT_ORG_ID:-}
- LANGFUSE_INIT_ORG_NAME=${LANGFUSE_INIT_ORG_NAME:-}
- LANGFUSE_INIT_PROJECT_ID=${LANGFUSE_INIT_PROJECT_ID:-}
- LANGFUSE_INIT_PROJECT_NAME=${LANGFUSE_INIT_PROJECT_NAME:-}
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
- LANGFUSE_INIT_PROJECT_SECRET_KEY=${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
- LANGFUSE_INIT_USER_EMAIL=${LANGFUSE_INIT_USER_EMAIL:-}
- LANGFUSE_INIT_USER_NAME=${LANGFUSE_INIT_USER_NAME:-}
- LANGFUSE_INIT_USER_PASSWORD=${LANGFUSE_INIT_USER_PASSWORD:-}
<<: *langfuse-worker-env
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: mysecret
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-}
LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-}
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: ${LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED:-false}
LANGFUSE_READ_FROM_POSTGRES_ONLY: ${LANGFUSE_READ_FROM_POSTGRES_ONLY:-false}
LANGFUSE_READ_FROM_CLICKHOUSE_ONLY: ${LANGFUSE_READ_FROM_CLICKHOUSE_ONLY:-true}
LANGFUSE_RETURN_FROM_CLICKHOUSE: ${LANGFUSE_RETURN_FROM_CLICKHOUSE:-true}
db:
image: postgres
clickhouse:
image: clickhouse/clickhouse-server
restart: always
user: "101:101"
container_name: clickhouse
hostname: clickhouse
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
interval: 5s
timeout: 5s
retries: 10
start_period: 1s
minio:
image: minio/minio
restart: always
container_name: 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'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
ports:
- "9090:9000"
- "9091:9001"
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
redis:
image: redis:7
restart: always
command: >
--requirepass ${REDIS_AUTH:-myredissecret}
ports:
- 6379:6379
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 10s
retries: 10
postgres:
image: postgres:${POSTGRES_VERSION:-latest}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
@@ -33,14 +133,20 @@ services:
timeout: 3s
retries: 10
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- database_data:/var/lib/postgresql/data
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
database_data:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_minio_data:
driver: local
+2 -2
View File
@@ -28,7 +28,7 @@
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"axios": "^1.7.7",
"next": "^14.2.15",
"next-auth": "^4.24.7",
"next-auth": "^4.24.11",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -47,7 +47,7 @@
},
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.0"
"jsonpath-plus": "10.0.7"
}
}
}
+2 -5
View File
@@ -1,12 +1,9 @@
import { z } from "zod";
import { env as sharedEnv, removeEmptyEnvVariables } from "@langfuse/shared";
import { removeEmptyEnvVariables } from "@langfuse/shared";
const EnvSchema = z.object({
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: z.string().optional(),
LANGFUSE_EE_LICENSE_KEY: z.string().optional(),
});
export const env = {
...sharedEnv,
...EnvSchema.parse(removeEmptyEnvVariables(process.env)),
};
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
+1 -1
View File
@@ -58,7 +58,7 @@ types:
docs: The id of the object to attach the comment to. If this does not reference a valid existing object, an error will be thrown.
content:
type: string
docs: The content of the comment. May include markdown. Currently limited to 500 characters.
docs: The content of the comment. May include markdown. Currently limited to 3000 characters.
authorUserId:
type: optional<string>
docs: The id of the user who created the comment.
+11 -5
View File
@@ -126,7 +126,7 @@ types:
docs: The output data of the observation
usage:
type: optional<Usage>
docs: The usage data of the observation
docs: (Deprecated. Use usageDetails and costDetails instead.) The usage data of the observation
level:
type: ObservationLevel
docs: The level of the observation
@@ -139,6 +139,12 @@ types:
promptId:
type: optional<string>
docs: The prompt ID associated with the observation
usageDetails:
type: optional<map<string, integer>>
docs: The usage details of the observation. Key is the name of the usage metric, value is the number of units consumed. The total key is the sum of all (non-total) usage metrics or the total value ingested.
costDetails:
type: optional<map<string, double>>
docs: The cost details of the observation. Key is the name of the cost metric, value is the cost in USD. The total key is the sum of all (non-total) cost metrics or the total value ingested.
ObservationsView:
extends: Observation
@@ -163,13 +169,13 @@ types:
docs: The total price in USD.
calculatedInputCost:
type: optional<double>
docs: The calculated cost of the input in USD
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the input in USD
calculatedOutputCost:
type: optional<double>
docs: The calculated cost of the output in USD
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated cost of the output in USD
calculatedTotalCost:
type: optional<double>
docs: The calculated total cost in USD
docs: (Deprecated. Use usageDetails and costDetails instead.) The calculated total cost in USD
latency:
type: optional<double>
docs: The latency in seconds.
@@ -178,7 +184,7 @@ types:
docs: The time to the first token in seconds
Usage:
docs: Standard interface for usage and cost
docs: (Deprecated. Use usageDetails and costDetails instead.) Standard interface for usage and cost
properties:
input:
docs: Number of input units (e.g. tokens)
+18
View File
@@ -126,6 +126,8 @@ types:
model: optional<string>
modelParameters: optional<map<string, commons.MapValue>>
usage: optional<IngestionUsage>
usageDetails: optional<UsageDetails>
costDetails: optional<map<string, float>>
promptName: optional<string>
promptVersion: optional<integer>
@@ -137,6 +139,8 @@ types:
modelParameters: optional<map<string, commons.MapValue>>
usage: optional<IngestionUsage>
promptName: optional<string>
usageDetails: optional<UsageDetails>
costDetails: optional<map<string, float>>
promptVersion: optional<integer>
ObservationBody:
@@ -312,3 +316,17 @@ types:
properties:
successes: list<IngestionSuccess>
errors: list<IngestionError>
OpenAIUsageSchema:
properties:
prompt_tokens: integer
completion_tokens: integer
total_tokens: integer
prompt_tokens_details: optional<map<string, integer>>
completion_tokens_details: optional<map<string, integer>>
UsageDetails:
discriminated: false
union:
- map<string, integer>
- OpenAIUsageSchema
+59 -1
View File
@@ -99,5 +99,63 @@ types:
docs: The unique langfuse identifier of a media record
MediaContentType:
type: literal<"image/png","image/jpeg","image/jpg","image/webp","audio/mpeg","audio/mp3","audio/wav","text/plain","application/pdf">
enum:
- value: image/png
name: IMAGE_PNG
- value: image/jpeg
name: IMAGE_JPEG
- value: image/jpg
name: IMAGE_JPG
- value: image/webp
name: IMAGE_WEBP
- value: image/gif
name: IMAGE_GIF
- value: image/svg+xml
name: IMAGE_SVG_XML
- value: image/tiff
name: IMAGE_TIFF
- value: image/bmp
name: IMAGE_BMP
- value: audio/mpeg
name: AUDIO_MPEG
- value: audio/mp3
name: AUDIO_MP3
- value: audio/wav
name: AUDIO_WAV
- value: audio/ogg
name: AUDIO_OGG
- value: audio/oga
name: AUDIO_OGA
- value: audio/aac
name: AUDIO_AAC
- value: audio/mp4
name: AUDIO_MP4
- value: audio/flac
name: AUDIO_FLAC
- value: video/mp4
name: VIDEO_MP4
- value: video/webm
name: VIDEO_WEBM
- value: text/plain
name: TEXT_PLAIN
- value: text/html
name: TEXT_HTML
- value: text/css
name: TEXT_CSS
- value: text/csv
name: TEXT_CSV
- value: application/pdf
name: APPLICATION_PDF
- value: application/msword
name: APPLICATION_MSWORD
- value: application/vnd.ms-excel
name: APPLICATION_MS_EXCEL
- value: application/zip
name: APPLICATION_ZIP
- value: application/json
name: APPLICATION_JSON
- value: application/xml
name: APPLICATION_XML
- value: application/octet-stream
name: APPLICATION_OCTET_STREAM
docs: The MIME type of the media record
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "2.91.0",
"version": "3.2.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -81,10 +81,10 @@
}
}
},
"packageManager": "pnpm@9.5.0",
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.0"
"jsonpath-plus": "10.0.7"
}
},
"packageManager": "pnpm@9.5.0"
}
}
@@ -0,0 +1 @@
ALTER TABLE traces ON CLUSTER default DROP INDEX IF EXISTS idx_user_id;
@@ -0,0 +1,2 @@
ALTER TABLE traces ON CLUSTER default ADD INDEX IF NOT EXISTS idx_user_id user_id TYPE bloom_filter() GRANULARITY 1;
ALTER TABLE traces ON CLUSTER default MATERIALIZE INDEX IF EXISTS idx_user_id;
@@ -0,0 +1 @@
ALTER TABLE traces ON CLUSTER default DROP INDEX IF EXISTS idx_user_id;
@@ -0,0 +1,2 @@
ALTER TABLE traces ADD INDEX IF NOT EXISTS idx_user_id user_id TYPE bloom_filter() GRANULARITY 1;
ALTER TABLE traces MATERIALIZE INDEX IF EXISTS idx_user_id;
+10 -10
View File
@@ -19,16 +19,7 @@ then
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_CLUSTER_DISABLED" != "true" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" down
else
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
@@ -37,4 +28,13 @@ else
# Execute the up command
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" down
else
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" down
fi
+2 -2
View File
@@ -2,7 +2,7 @@ import {
clickhouseClient,
ObservationRecordReadType,
} from "@langfuse/shared/src/server";
import { Prisma, prisma } from "../../src/db";
import { prisma } from "../../src/db";
import { redis } from "@langfuse/shared/src/server";
import { prepareClickhouse } from "../../scripts/prepareClickhouse";
import { createDatasets } from "../../prisma/seed";
@@ -65,7 +65,7 @@ async function main() {
} catch (error) {
console.error("Error during Clickhouse preparation:", error);
} finally {
await clickhouseClient.close();
await clickhouseClient().close();
await prisma.$disconnect();
redis?.disconnect();
console.log("Disconnected from Clickhouse.");
+10 -10
View File
@@ -19,16 +19,7 @@ then
fi
# Construct the database URL
if [ "$CLICKHOUSE_MIGRATION_CLUSTER_DISABLED" != "true" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" up
else
if [ "$CLICKHOUSE_CLUSTER_ENABLED" == "false" ] ; then
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-migrations-table-engine=MergeTree"
else
@@ -37,4 +28,13 @@ else
# Execute the up command
migrate -source file://clickhouse/migrations/unclustered -database "$DATABASE_URL" up
else
if [ "$CLICKHOUSE_MIGRATION_SSL" = true ] ; then
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&secure=true&skip_verify=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
else
DATABASE_URL="${CLICKHOUSE_MIGRATION_URL}?username=${CLICKHOUSE_USER}&password=${CLICKHOUSE_PASSWORD}&database=default&x-multi-statement=true&x-cluster-name=default&x-migrations-table-engine=ReplicatedMergeTree"
fi
# Execute the up command
migrate -source file://clickhouse/migrations/clustered -database "$DATABASE_URL" up
fi
+10 -7
View File
@@ -59,11 +59,13 @@
"@aws-sdk/client-s3": "^3.675.0",
"@aws-sdk/lib-storage": "^3.675.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.4.0",
"@langchain/anthropic": "^0.3.1",
"@langchain/aws": "^0.1.0",
"@langchain/core": "^0.3.9",
"@langchain/openai": "^0.3.0",
"@langchain/anthropic": "^0.3.8",
"@langchain/aws": "^0.1.2",
"@langchain/core": "^0.3.18",
"@langchain/google-vertexai": "^0.1.3",
"@langchain/openai": "^0.3.14",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"@prisma/client": "^5.22.0",
"@react-email/components": "^0.0.19",
@@ -77,9 +79,10 @@
"exponential-backoff": "^3.1.1",
"ioredis": "^5.4.1",
"kysely": "^0.27.4",
"langchain": "^0.3.2",
"langchain": "^0.3.6",
"langfuse-langchain": "3.30.3",
"lodash": "^4.17.21",
"next-auth": "^4.24.7",
"next-auth": "^4.24.11",
"nodemailer": "^6.9.15",
"prisma-extension-kysely": "^2.1.0",
"uuid": "^9.0.1",
@@ -118,7 +121,7 @@
},
"pnpm": {
"overrides": {
"jsonpath-plus": "10.0.0"
"jsonpath-plus": "10.0.7"
}
}
}
@@ -453,6 +453,7 @@ export type Project = {
org_id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
deleted_at: Timestamp | null;
name: string;
};
export type ProjectMembership = {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
@@ -0,0 +1,25 @@
INSERT INTO models (
id,
project_id,
model_name,
match_pattern,
start_date,
input_price,
output_price,
total_price,
unit,
tokenizer_id,
tokenizer_config
)
VALUES
('cm3x0p8ev000008kyd96800c8', NULL, 'chatgpt-4o-latest', '(?i)^(chatgpt-4o-latest)$', NULL, 0.000005, 0.000015, NULL, 'TOKENS', 'openai', '{ "tokensPerMessage": 3, "tokensPerName": 1, "tokenizerModel": "gpt-4o" }');
INSERT INTO prices (
id,
model_id,
usage_type,
price
)
VALUES
('cm3x0psrz000108kydpxg9o2k', 'cm3x0p8ev000008kyd96800c8', 'input', 0.000005),
('cm3x0pyt7000208ky8737gdla', 'cm3x0p8ev000008kyd96800c8', 'output', 0.000015);
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "job_executions" DROP CONSTRAINT "job_executions_job_output_score_id_fkey";
-- DropForeignKey
ALTER TABLE "traces" DROP CONSTRAINT "traces_session_id_project_id_fkey";
+16 -17
View File
@@ -113,6 +113,7 @@ model Project {
orgId String @map("org_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
name String
projectMembers ProjectMembership[]
organization Organization @relation(fields: [orgId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@@ -468,25 +469,24 @@ enum ObservationLevel {
}
model Score {
id String @id @default(cuid())
timestamp DateTime @default(now())
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
timestamp DateTime @default(now())
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
value Float? // always defined if data type is NUMERIC or BOOLEAN, optional for CATEGORICAL
source ScoreSource
authorUserId String? @map("author_user_id")
authorUserId String? @map("author_user_id")
comment String?
traceId String @map("trace_id")
observationId String? @map("observation_id")
configId String? @map("config_id")
stringValue String? @map("string_value") // always defined if data type is CATEGORICAL or BOOLEAN, null for NUMERIC
queueId String? @map("queue_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
dataType ScoreDataType @default(NUMERIC) @map("data_type")
JobExecution JobExecution[]
scoreConfig ScoreConfig? @relation(fields: [configId], references: [id], onDelete: SetNull)
traceId String @map("trace_id")
observationId String? @map("observation_id")
configId String? @map("config_id")
stringValue String? @map("string_value") // always defined if data type is CATEGORICAL or BOOLEAN, null for NUMERIC
queueId String? @map("queue_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
dataType ScoreDataType @default(NUMERIC) @map("data_type")
scoreConfig ScoreConfig? @relation(fields: [configId], references: [id], onDelete: SetNull)
@@unique([id, projectId]) // used for upserts via prisma
@@index(timestamp)
@@ -900,10 +900,9 @@ model JobExecution {
jobInputObservationId String? @map("job_input_observation_id") // no fk constraint - observations in ClickHouse, deletion handled via project cascade
jobInputDatasetItemId String? @map("job_input_dataset_item_id") // no fk constraint - job execution sensible standalone
jobInputDatasetItemId String? @map("job_input_dataset_item_id") // no fk constraint - job execution sensible standalone
jobOutputScoreId String? @map("job_output_score_id")
score Score? @relation(fields: [jobOutputScoreId], references: [id], onDelete: SetNull) // job remains when scores are deleted
@@index([projectId, status])
@@index([projectId, id])
+6
View File
@@ -450,6 +450,9 @@ export async function createDatasets(
Math.random() > 0.3
? observations[Math.floor(Math.random() * observations.length)]
: undefined;
if (!sourceObservation) {
continue;
}
const datasetItem = await prisma.datasetItem.create({
data: {
projectId,
@@ -510,6 +513,9 @@ export async function createDatasets(
Math.floor(Math.random() * relevantObservations.length)
];
if (!observation) {
continue;
}
await prisma.datasetRunItems.create({
data: {
projectId,
+1 -1
View File
@@ -124,7 +124,7 @@ async function main() {
} catch (error) {
logger.error("Error during Clickhouse preparation:", error);
} finally {
await clickhouseClient.close();
await clickhouseClient().close();
await prisma.$disconnect();
redis?.disconnect();
logger.info("Disconnected from Clickhouse.");
+5 -5
View File
@@ -110,7 +110,7 @@ export const prepareClickhouse = async (
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)) AS provided_cost_details,
map('input', toDecimal64(randUniform(0, 1000), 12), 'output', toDecimal64(randUniform(0, 1000), 12), 'total', toDecimal64(randUniform(0, 2000), 12)) AS cost_details,
toDecimal64(randUniform(0, 2000), 12) AS total_cost,
start_time AS completion_start_time,
addMilliseconds(start_time, if(rand() < 0.6, floor(randUniform(0, 500)), floor(randUniform(0, 600)))) AS completion_start_time,
array(${SEED_PROMPTS.map((p) => `concat('${p.id}',project_id)`).join(
",",
)})[(number % ${SEED_PROMPTS.length})+1] AS prompt_id,
@@ -160,7 +160,7 @@ export const prepareClickhouse = async (
for (const query of queries) {
logger.info(`Executing query: ${query}`);
await clickhouseClient.command({
await clickhouseClient().command({
query,
clickhouse_settings: {
wait_end_of_query: 1,
@@ -174,7 +174,7 @@ export const prepareClickhouse = async (
FROM traces
WHERE session_id IS NOT NULL;
`;
const sessionResult = await clickhouseClient.query({
const sessionResult = await clickhouseClient().query({
query: sessionQuery,
format: "JSONEachRow",
});
@@ -212,7 +212,7 @@ export const prepareClickhouse = async (
ORDER BY count() desc
`;
const result = await clickhouseClient.query({
const result = await clickhouseClient().query({
query,
format: "TabSeparated",
});
@@ -243,7 +243,7 @@ export const prepareClickhouse = async (
ORDER BY event_date desc
`;
const result = await clickhouseClient.query({
const result = await clickhouseClient().query({
query,
format: "TabSeparated",
});
+28 -7
View File
@@ -5,6 +5,7 @@ const EnvSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
NEXTAUTH_URL: z.string().url().optional(),
REDIS_HOST: z.string().nullish(),
REDIS_PORT: z.coerce
.number({
@@ -22,14 +23,28 @@ const EnvSchema = z.object({
.string()
.length(
64,
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32"
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
)
.optional(),
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("false"),
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(60 * 60),
CLICKHOUSE_URL: z.string().url().optional(),
CLICKHOUSE_USER: z.string().optional(),
CLICKHOUSE_PASSWORD: z.string().optional(),
CLICKHOUSE_URL: z.string().url(),
CLICKHOUSE_USER: z.string(),
CLICKHOUSE_PASSWORD: z.string(),
LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_CLICKHOUSE_INGESTION_ENABLED: z
.enum(["true", "false"])
.default("true"),
LANGFUSE_POSTGRES_INGESTION_ENABLED: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_INGESTION_QUEUE_DELAY_MS: z.coerce
.number()
.nonnegative()
.default(15_000),
SALT: z.string().optional(), // used by components imported by web package
LANGFUSE_LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
@@ -38,8 +53,9 @@ const EnvSchema = z.object({
ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_S3_EVENT_UPLOAD_ENABLED: z.enum(["true", "false"]).default("false"),
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string().optional(),
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: z.string({
required_error: "Langfuse requires a bucket name for S3 Event Uploads.",
}),
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: z.string().default(""),
LANGFUSE_S3_EVENT_UPLOAD_REGION: z.string().optional(),
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: z.string().optional(),
@@ -48,6 +64,11 @@ const EnvSchema = z.object({
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
STRIPE_SECRET_KEY: z.string().optional(),
});
export const env = EnvSchema.parse(removeEmptyEnvVariables(process.env));
export const env: z.infer<typeof EnvSchema> =
process.env.DOCKER_BUILD === "1"
? (process.env as any)
: EnvSchema.parse(removeEmptyEnvVariables(process.env));
@@ -1,5 +1,7 @@
import { z } from "zod";
const MAX_COMMENT_LENGTH = 3000;
const COMMENT_OBJECT_TYPES = [
"TRACE",
"OBSERVATION",
@@ -9,7 +11,7 @@ const COMMENT_OBJECT_TYPES = [
export const CreateCommentData = z.object({
projectId: z.string(),
content: z.string().trim().min(1).max(500),
content: z.string().trim().min(1).max(MAX_COMMENT_LENGTH),
objectId: z.string(),
objectType: z.enum(COMMENT_OBJECT_TYPES),
});
@@ -3,15 +3,17 @@ export const planLabels = {
"cloud:hobby": "Hobby",
"cloud:pro": "Pro",
"cloud:team": "Team",
"self-hosted:enterprise": "Enterprise",
"self-hosted:pro": "Pro (self-hosted)",
"self-hosted:enterprise": "Enterprise (self-hosted)",
} as const;
export type Plan = keyof typeof planLabels;
export const plans = Object.keys(planLabels) as Plan[];
// This function is kept here to ensure consistency when updating plan names in the future.
// These functions are kept here to ensure consistency when updating plan names in the future.
export const isCloudPlan = (plan: Plan) => plan.startsWith("cloud");
export const isSelfHostedPlan = (plan: Plan) => plan.startsWith("self-hosted");
export const isPlan = (value: string): value is Plan =>
plans.includes(value as Plan);
@@ -0,0 +1,15 @@
import { Prisma } from "../../db";
export const datasetItemMatchesVariable = (
input: Prisma.JsonValue,
variable: string,
) => {
if (
input === null ||
input === undefined ||
typeof input !== "object" ||
Array.isArray(input)
)
return false;
return Object.keys(input).includes(variable);
};
@@ -6,7 +6,7 @@ import { isPresent } from "../../utils/typeChecks";
import {
jsonSchema,
paginationMetaResponseZod,
paginationZod,
publicApiPaginationZod,
} from "../../utils/zod";
/**
@@ -17,7 +17,7 @@ export type ValidatedScoreConfig = z.infer<typeof ValidatedScoreConfigSchema>;
const validateCategories = (
categories: ConfigCategory[],
ctx: z.RefinementCtx
ctx: z.RefinementCtx,
) => {
const uniqueNames = new Set<string>();
const uniqueValues = new Set<number>();
@@ -94,7 +94,7 @@ const BooleanScoreConfig = z.object({
return categories.every(
(category, index) =>
category.label === expectedCategories[index].label &&
category.value === expectedCategories[index].value
category.value === expectedCategories[index].value,
);
}),
});
@@ -123,7 +123,7 @@ const ValidatedScoreConfigSchema = z
minValue: z.undefined().nullish(),
dataType: z.literal("CATEGORICAL"),
categories: Categories.superRefine(validateCategories),
})
}),
),
ScoreConfigBase.merge(BooleanScoreConfig),
])
@@ -150,7 +150,7 @@ const ValidatedScoreConfigSchema = z
*/
export const filterAndValidateDbScoreConfigList = (
scoreConfigs: ScoreConfigDbType[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): ValidatedScoreConfig[] =>
scoreConfigs.reduce((acc, ts) => {
const result = ValidatedScoreConfigSchema.safeParse(ts);
@@ -170,7 +170,7 @@ export const filterAndValidateDbScoreConfigList = (
* @throws error if score fails validation
*/
export const validateDbScoreConfig = (
scoreConfig: ScoreConfigDbType
scoreConfig: ScoreConfigDbType,
): ValidatedScoreConfig => ValidatedScoreConfigSchema.parse(scoreConfig);
/**
@@ -205,7 +205,7 @@ export const PostScoreConfigBody = z
z.object({
dataType: z.literal("BOOLEAN"),
categories: z.undefined().nullish(),
})
}),
),
])
.superRefine((data, ctx) => {
@@ -227,7 +227,7 @@ export const PostScoreConfigResponse = ValidatedScoreConfigSchema;
// GET /score-configs
export const GetScoreConfigsQuery = z.object({
...paginationZod,
...publicApiPaginationZod,
});
export const GetScoreConfigsResponse = z.object({
@@ -6,7 +6,7 @@ import { isPresent, stringDateTime } from "../../utils/typeChecks";
import {
NonEmptyString,
paginationMetaResponseZod,
paginationZod,
publicApiPaginationZod,
} from "../../utils/zod";
import { Category as ConfigCategory } from "./scoreConfigTypes";
@@ -84,13 +84,13 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
z.object({
value: z.number(),
dataType: z.literal("NUMERIC"),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
})
}),
),
BaseScoreBody.merge(
z.object({
@@ -98,7 +98,7 @@ export const ScoreBodyWithoutConfig = z.discriminatedUnion("dataType", [
message: "Value must be either 0 or 1",
}),
dataType: z.literal("BOOLEAN"),
})
}),
),
]);
@@ -162,7 +162,7 @@ export const ScorePropsAgainstConfig = z.union([
*/
export const filterAndValidateDbScoreList = (
scores: Score[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): APIScore[] =>
scores.reduce((acc, ts) => {
const result = APIScoreSchema.safeParse(ts);
@@ -199,14 +199,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
value: z.number(),
dataType: z.literal("NUMERIC"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.string(),
dataType: z.literal("CATEGORICAL"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
@@ -216,14 +216,14 @@ export const PostScoresBody = z.discriminatedUnion("dataType", [
}),
dataType: z.literal("BOOLEAN"),
configId: z.string().nullish(),
})
}),
),
BaseScoreBody.merge(
z.object({
value: z.union([z.string(), z.number()]),
dataType: z.undefined(),
configId: z.string().nullish(),
})
}),
),
]);
@@ -231,7 +231,7 @@ export const PostScoresResponse = z.object({ id: z.string() });
// GET /scores
export const GetScoresQuery = z.object({
...paginationZod,
...publicApiPaginationZod,
userId: z.string().nullish(),
dataType: z.enum(ScoreDataType).nullish(),
configId: z.string().nullish(),
@@ -260,7 +260,7 @@ const LegacyGetScoreResponseDataV1 = z.intersection(
userId: z.string().nullish(),
tags: z.array(z.string()).nullish(),
}),
})
}),
);
export const GetScoresResponse = z.object({
data: z.array(LegacyGetScoreResponseDataV1),
@@ -269,7 +269,7 @@ export const GetScoresResponse = z.object({
export const legacyFilterAndValidateV1GetScoreList = (
scores: unknown[],
onParseError?: (error: z.ZodError) => void
onParseError?: (error: z.ZodError) => void,
): z.infer<typeof LegacyGetScoreResponseDataV1>[] =>
scores.reduce(
(acc: z.infer<typeof LegacyGetScoreResponseDataV1>[], ts) => {
@@ -282,7 +282,7 @@ export const legacyFilterAndValidateV1GetScoreList = (
}
return acc;
},
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[]
[] as z.infer<typeof LegacyGetScoreResponseDataV1>[],
);
// GET /scores/{scoreId}
+4 -2
View File
@@ -7,15 +7,14 @@ export * from "./interfaces/customLLMProviderConfigSchemas";
export * from "./tableDefinitions";
export * from "./types";
export * from "./tableDefinitions/tracesTable";
export * from "./server/auth/apiKeys";
export * from "./observationsTable";
export * from "./utils/zod";
export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export { env } from "./env";
// llm api
export * from "./server/llm/types";
@@ -33,6 +32,9 @@ export * from "./features/scores";
// comments
export * from "./features/comments/types";
// experiments
export * from "./features/experiments/utils";
// export db types only
export * from "@prisma/client";
export { type DB } from "../prisma/generated/types";
@@ -10,3 +10,19 @@ export const BedrockCredentialSchema = z
})
.optional();
export type BedrockCredential = z.infer<typeof BedrockCredentialSchema>;
export const GCPServiceAccountKeySchema = z.object({
type: z.literal("service_account"),
project_id: z.string(),
private_key_id: z.string(),
private_key: z.string(),
client_email: z.string(),
client_id: z.string(),
auth_uri: z.string(),
token_uri: z.string(),
auth_provider_x509_cert_url: z.string(),
client_x509_cert_url: z.string(),
});
export type GCPServiceAccountKey = z.infer<typeof GCPServiceAccountKeySchema>;
export default GCPServiceAccountKeySchema;
@@ -0,0 +1,65 @@
import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";
import type { GithubProfile, GithubEmail } from "next-auth/providers/github";
export function GitHubEnterpriseProvider<P extends GithubProfile>(
options: OAuthUserConfig<P> & {
enterprise?: {
baseUrl?: string;
};
}
): OAuthConfig<P> {
const baseUrl = options?.enterprise?.baseUrl ?? "https://github.com"
const apiBaseUrl = options?.enterprise?.baseUrl
? `${options?.enterprise?.baseUrl}/api/v3`
: "https://api.github.com"
return {
id: "github-enterprise",
name: "GitHub Enterprise",
type: "oauth",
authorization: {
url: `${baseUrl}/login/oauth/authorize`,
params: { scope: "read:user user:email" },
},
token: `${baseUrl}/login/oauth/access_token`,
userinfo: {
url: `${apiBaseUrl}/user`,
async request({ client, tokens }) {
const profile = await client.userinfo(tokens.access_token!)
if (!profile.email) {
// If the user does not have a public email, get another via the GitHub API
// See https://docs.github.com/en/rest/users/emails#list-email-addresses-for-the-authenticated-user
const res = await fetch(`${apiBaseUrl}/user/emails`, {
headers: { Authorization: `token ${tokens.access_token}` },
})
if (res.ok) {
const emails: GithubEmail[] = await res.json()
profile.email = (emails.find((e) => e.primary) ?? emails[0]).email
}
}
return profile
},
},
profile(profile) {
return {
id: profile.id.toString(),
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,
}
},
style: {
logo: "https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/next-auth/provider-logos/github.svg",
logoDark:
"https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/next-auth/provider-logos/github-dark.svg",
bg: "#fff",
bgDark: "#000",
text: "#000",
textDark: "#fff",
},
options,
}
}
+14 -10
View File
@@ -1,19 +1,23 @@
import { createClient } from "@clickhouse/client";
import { env } from "../../env";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
export type ClickhouseClientType = ReturnType<typeof createClient>;
export const clickhouseClient = createClient({
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: "default",
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
},
});
export const clickhouseClient = (opts?: NodeClickHouseClientConfigOptions) =>
createClient({
...opts,
url: env.CLICKHOUSE_URL,
username: env.CLICKHOUSE_USER,
password: env.CLICKHOUSE_PASSWORD,
database: "default",
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
},
});
export const defaultClickhouseClient = clickhouseClient();
/**
* Accepts a JavaScript date and returns the DateTime in format YYYY-MM-DD HH:MM:SS
*/
+15 -1
View File
@@ -1,10 +1,12 @@
export * from "./services/S3StorageService";
export * from "./services/StorageService";
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
export * from "./services/PromptService";
export * from "./services/traces-ui-table-service";
export * from "./auth/apiKeys";
export * from "./auth/customSsoProvider";
export * from "./auth/gitHubEnterpriseProvider";
export * from "./llm/fetchLLMCompletion";
export * from "./llm/types";
export * from "./utils/DatabaseReadStream";
@@ -15,14 +17,22 @@ export * from "./clickhouse/schema";
export * from "./repositories/definitions";
export * from "../server/ingestion/types";
export * from "./ingestion/modelMatch";
export * from "./ingestion/processEventBatch";
export * from "../server/ingestion/types";
export * from "../server/ingestion/validateAndInflateScore";
export * from "./redis/redis";
export * from "./redis/traceUpsert";
export * from "./redis/cloudUsageMeteringQueue";
export * from "./redis/getQueue";
export * from "./redis/traceDelete";
export * from "./redis/projectDelete";
export * from "./redis/datasetRunItemUpsert";
export * from "./redis/batchExport";
export * from "./redis/legacyIngestion";
export * from "./redis/ingestionQueue";
export * from "./redis/postHogIntegrationQueue";
export * from "./redis/postHogIntegrationProcessingQueue";
export * from "./redis/experimentCreateQueue";
export * from "./auth/types";
export * from "./ingestion/legacy/index";
export * from "./queues";
@@ -33,3 +43,7 @@ export * from "./instrumentation";
export * from "./logger";
export * from "./queries";
export * from "./repositories";
export * from "./redis/evalExecutionQueue";
// test utils
export * from "./test-utils";
@@ -136,7 +136,7 @@ export class ObservationProcessor implements EventProcessor {
// Token counts
const [newInputCount, newOutputCount] =
"usage" in this.event.body
"usage" in this.event.body || "usageDetails" in this.event.body
? await this.calculateTokenCounts(
apiScope.projectId,
this.event.body,
@@ -147,8 +147,9 @@ export class ObservationProcessor implements EventProcessor {
: [undefined, undefined];
const newTotalCount =
"usage" in this.event.body
"usage" in this.event.body || "usageDetails" in this.event.body
? (this.event.body.usage?.total ??
this.event.body.usageDetails?.total ??
(newInputCount != null || newOutputCount != null
? (newInputCount ?? 0) + (newOutputCount ?? 0)
: undefined))
@@ -158,15 +159,24 @@ export class ObservationProcessor implements EventProcessor {
inputCost:
"usage" in this.event.body && this.event.body.usage?.inputCost != null // inputCost can be explicitly 0. Note only one equal sign to capture null AND undefined
? new Decimal(this.event.body.usage?.inputCost)
: existingObservation?.inputCost,
: "costDetails" in this.event.body &&
this.event.body.costDetails?.input != null
? new Decimal(this.event.body.costDetails?.input)
: existingObservation?.inputCost,
outputCost:
"usage" in this.event.body && this.event.body.usage?.outputCost != null // outputCost can be explicitly 0. Note only one equal sign to capture null AND undefined
? new Decimal(this.event.body.usage?.outputCost)
: existingObservation?.outputCost,
: "costDetails" in this.event.body &&
this.event.body.costDetails?.output != null
? new Decimal(this.event.body.costDetails?.output)
: existingObservation?.outputCost,
totalCost:
"usage" in this.event.body && this.event.body.usage?.totalCost != null // totalCost can be explicitly 0. Note only one equal sign to capture null AND undefined
? new Decimal(this.event.body.usage?.totalCost)
: existingObservation?.totalCost,
: "costDetails" in this.event.body &&
this.event.body.costDetails?.total != null
? new Decimal(this.event.body.costDetails?.total)
: existingObservation?.totalCost,
};
const tokenCounts = {
@@ -384,7 +394,7 @@ export class ObservationProcessor implements EventProcessor {
model?: Model,
existingObservation?: Omit<Observation, "input" | "output">,
) {
let newPromptTokens = body.usage?.input;
let newPromptTokens = body.usage?.input || body.usageDetails?.input;
if (newPromptTokens === undefined && model && model.tokenizerId) {
if (body.input) {
newPromptTokens = calculateTokenDelegate({
@@ -409,7 +419,7 @@ export class ObservationProcessor implements EventProcessor {
}
}
let newCompletionTokens = body.usage?.output;
let newCompletionTokens = body.usage?.output || body.usageDetails?.output;
if (newCompletionTokens === undefined && model && model.tokenizerId) {
if (body.output) {
@@ -3,13 +3,7 @@ import z from "zod";
import { ForbiddenError, UnauthorizedError } from "../../../errors";
import { eventTypes, ingestionApiSchema, IngestionEventType } from "../types";
import { getProcessorForEvent } from "./EventProcessor";
import { TraceUpsertEventType } from "../../queues";
import {
convertTraceUpsertEventsToRedisEvents,
TraceUpsertQueue,
} from "../../redis/traceUpsert";
import { ApiAccessScope } from "../../auth/types";
import { redis } from "../../redis/redis";
import { backOff } from "exponential-backoff";
import { Model } from "../../..";
import { logger } from "../../logger";
@@ -89,7 +83,7 @@ export const handleBatch = async (
async function retry<T>(request: () => Promise<T>): Promise<T> {
return await backOff(request, {
numOfAttempts: env.LANGFUSE_ASYNC_INGESTION_PROCESSING === "true" ? 5 : 3,
numOfAttempts: 5,
retry: (e: Error, attemptNumber: number) => {
if (e instanceof UnauthorizedError || e instanceof ForbiddenError) {
logger.info("not retrying auth error");
@@ -163,45 +157,5 @@ export function cleanEvent(obj: unknown): unknown {
}
}
export const isNotNullOrUndefined = <T>(
val?: T | null,
): val is Exclude<T, null | undefined> => !isUndefinedOrNull(val);
export const isUndefinedOrNull = <T>(val?: T | null): val is undefined | null =>
val === undefined || val === null;
export const addTracesToTraceUpsertQueue = async (
batchResults: BatchResult[],
projectId: string,
): Promise<void> => {
const traceEvents: TraceUpsertEventType[] = batchResults
.filter((result) => result.type === eventTypes.TRACE_CREATE) // we only have create, no update.
.map((result) =>
result.result &&
typeof result.result === "object" &&
"id" in result.result
? // ingestion API only gets traces for one projectId
{
traceId: result.result.id as string,
projectId,
}
: null,
)
.filter(isNotNullOrUndefined);
try {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && redis) {
logger.debug(`Sending ${traceEvents.length} events to worker via Redis`);
const queue = TraceUpsertQueue.getInstance();
if (!queue) {
logger.error("TraceUpsertQueue not initialized");
return;
}
await queue.addBulk(convertTraceUpsertEventsToRedisEvents(traceEvents));
}
} catch (error) {
logger.error("Error sending events to worker", error);
}
};
@@ -0,0 +1,426 @@
import { randomUUID } from "crypto";
import { z } from "zod";
import { type Model } from "../../db";
import { env } from "../../env";
import {
InvalidRequestError,
LangfuseNotFoundError,
UnauthorizedError,
} from "../../errors";
import { AuthHeaderValidVerificationResult } from "../auth/types";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import {
getCurrentSpan,
instrumentAsync,
instrumentSync,
recordIncrement,
traceException,
} from "../instrumentation";
import { logger } from "../logger";
import { LegacyIngestionEventType, QueueJobs } from "../queues";
import { IngestionQueue } from "../redis/ingestionQueue";
import { LegacyIngestionQueue } from "../redis/legacyIngestion";
import { redis } from "../redis/redis";
import { handleBatch } from "./legacy";
import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { getProcessorForEvent } from "./legacy/EventProcessor";
import { eventTypes, ingestionEvent, IngestionEventType } from "./types";
export type TokenCountDelegate = (p: {
model: Model;
text: unknown;
}) => number | undefined;
let s3StorageServiceClient: StorageService;
const getS3StorageServiceClient = (bucketName: string): StorageService => {
if (!s3StorageServiceClient) {
s3StorageServiceClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
});
}
return s3StorageServiceClient;
};
export const processEventBatch = async (
input: unknown[],
authCheck: AuthHeaderValidVerificationResult,
tokenCountDelegate: TokenCountDelegate,
): Promise<{
successes: { id: string; status: number }[];
errors: {
id: string;
status: number;
message?: string;
error?: string;
}[];
}> => {
// add context of api call to the span
const currentSpan = getCurrentSpan();
recordIncrement("langfuse.ingestion.event", input.length);
currentSpan?.setAttribute("event_count", input.length);
/**************
* VALIDATION *
**************/
const validationErrors: { id: string; error: unknown }[] = [];
const authenticationErrors: { id: string; error: unknown }[] = [];
const batch: z.infer<typeof ingestionEvent>[] = input
.flatMap((event) => {
const parsed = instrumentSync(
{ name: "ingestion-zod-parse-individual-event" },
(span) => {
const parsedBody = ingestionEvent.safeParse(event);
if (parsedBody.data?.id !== undefined) {
span.setAttribute("object.id", parsedBody.data.id);
}
return parsedBody;
},
);
if (!parsed.success) {
validationErrors.push({
id:
typeof event === "object" && event && "id" in event
? typeof event.id === "string"
? event.id
: "unknown"
: "unknown",
error: new InvalidRequestError(parsed.error.message),
});
return [];
}
if (!isAuthorized(parsed.data, authCheck, tokenCountDelegate)) {
authenticationErrors.push({
id: parsed.data.id,
error: new UnauthorizedError("Access Scope Denied"),
});
return [];
}
return [parsed.data];
})
.flatMap((event) => {
if (event.type === eventTypes.SDK_LOG) {
// Log SDK_LOG events, but remove them from further processing
logger.info("SDK Log Event", { event });
return [];
}
return [event];
});
const sortedBatch = sortBatch(batch);
// We group events by eventBodyId which allows us to store and process them
// as one which reduces infra interactions per event. Only used in the S3 case.
const sortedBatchByEventBodyId = sortedBatch.reduce(
(
acc: Record<
string,
{
data: IngestionEventType[];
key: string;
eventBodyId: string;
type: (typeof eventTypes)[keyof typeof eventTypes];
}
>,
event,
) => {
if (!event.body?.id) {
return acc;
}
const key = `${getClickhouseEntityType(event.type)}-${event.body.id}`;
if (!acc[key]) {
acc[key] = {
data: [],
key: event.id,
type: event.type,
eventBodyId: event.body.id,
};
}
acc[key].data.push(event);
return acc;
},
{},
);
/********************
* ASYNC PROCESSING *
********************/
let s3UploadErrored = false;
await instrumentAsync({ name: "s3-upload-events" }, async () => {
const s3Client = getS3StorageServiceClient(
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
);
// S3 Event Upload is blocking, but non-failing.
// If a promise rejects, we log it below, but do not throw an error.
// In this case, we upload the full batch into the Redis queue.
const results = await Promise.allSettled(
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
// We upload the event in an array to the S3 bucket grouped by the eventBodyId.
// That way we batch updates from the same invocation into a single file and reduce
// write operations on S3.
const { data, key, type, eventBodyId } = sortedBatchByEventBodyId[id];
return s3Client.uploadJson(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${authCheck.scope.projectId}/${getClickhouseEntityType(type)}/${eventBodyId}/${key}.json`,
data,
);
}),
);
results.forEach((result) => {
if (result.status === "rejected") {
s3UploadErrored = true;
logger.error("Failed to upload event to S3", {
error: result.reason,
});
}
});
});
// This is a workaround to allow us to disable async ingestion processing for SDK CI testing
// TODO: remove this block after SDKs are ready for V3 async ingestion processing
if (env.LANGFUSE_SDK_CI_SYNC_PROCESSING_ENABLED === "true") {
const result = await handleBatch(
sortedBatch,
authCheck,
tokenCountDelegate,
);
// in case we did not return early, we return the result here
return aggregateBatchResult(
[...validationErrors, ...authenticationErrors, ...result.errors],
result.results,
authCheck.scope.projectId,
);
}
// Send each event individually to IngestionQueue for ClickHouse processing
if (env.LANGFUSE_CLICKHOUSE_INGESTION_ENABLED === "true") {
if (s3UploadErrored) {
throw new Error(
"Failed to upload events to blob storage, aborting event processing",
);
}
if (redis) {
const queue = IngestionQueue.getInstance();
await Promise.all(
Object.keys(sortedBatchByEventBodyId).map(async (id) =>
queue
? queue.add(
QueueJobs.IngestionJob,
{
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.IngestionJob as const,
payload: {
data: {
type: sortedBatchByEventBodyId[id].type,
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
},
authCheck,
},
},
{
delay: env.LANGFUSE_INGESTION_QUEUE_DELAY_MS,
},
)
: Promise.reject("Failed to instantiate queue"),
),
);
if (env.LANGFUSE_POSTGRES_INGESTION_ENABLED !== "true") {
// If postgres ingestion is disabled, we return early
return aggregateBatchResult(
[...validationErrors, ...authenticationErrors],
sortedBatch.map((event) => ({ id: event.id, result: event })),
authCheck.scope.projectId,
);
}
}
}
if (env.LANGFUSE_POSTGRES_INGESTION_ENABLED === "true") {
// As part of the legacy processing we sent the entire batch to the worker.
if (redis) {
const queue = LegacyIngestionQueue.getInstance();
if (queue) {
let addToQueueFailed = false;
const queuePayload: LegacyIngestionEventType = !s3UploadErrored
? {
data: Object.keys(sortedBatchByEventBodyId).map((id) => {
const { key, type, eventBodyId } = sortedBatchByEventBodyId[id];
return {
type,
eventBodyId,
eventId: key,
};
}),
authCheck,
useS3EventStore: true,
}
: { data: sortedBatch, authCheck, useS3EventStore: false };
try {
await queue.add(QueueJobs.LegacyIngestionJob, {
payload: queuePayload,
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.LegacyIngestionJob as const,
});
} catch (e: unknown) {
logger.warn(
"Failed to add batch to queue, falling back to sync processing",
e,
);
addToQueueFailed = true;
}
if (!addToQueueFailed) {
return aggregateBatchResult(
// we are not sending additional server errors to the client in case of early return
[...validationErrors, ...authenticationErrors],
sortedBatch.map((event) => ({ id: event.id, result: event })),
authCheck.scope.projectId,
);
}
} else {
logger.warn(
"Ingestion queue not initialized, falling back to sync processing",
);
}
}
/*******************
* SYNC PROCESSING *
*******************/
const result = await handleBatch(
sortedBatch,
authCheck,
tokenCountDelegate,
);
// in case we did not return early, we return the result here
return aggregateBatchResult(
[...validationErrors, ...authenticationErrors, ...result.errors],
result.results,
authCheck.scope.projectId,
);
}
throw new Error(
"Either Clickhouse or Postgres ingestion (or both) must be enabled",
);
};
const isAuthorized = (
event: IngestionEventType,
authScope: AuthHeaderValidVerificationResult,
tokenCountDelegate: TokenCountDelegate,
): boolean => {
try {
getProcessorForEvent(event, tokenCountDelegate).auth(authScope.scope);
return true;
} catch (error) {
return false;
}
};
/**
* Sorts a batch of ingestion events. Orders by: updating events last, sorted by timestamp asc.
*/
const sortBatch = (batch: Array<z.infer<typeof ingestionEvent>>) => {
const updateEvents: (typeof eventTypes)[keyof typeof eventTypes][] = [
eventTypes.GENERATION_UPDATE,
eventTypes.SPAN_UPDATE,
eventTypes.OBSERVATION_UPDATE, // legacy event type
];
const updates = batch
.filter((event) => updateEvents.includes(event.type))
.sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
const others = batch
.filter((event) => !updateEvents.includes(event.type))
.sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
// Return the array with non-update events first, followed by update events
return [...others, ...updates];
};
export const aggregateBatchResult = (
errors: Array<{ id: string; error: unknown }>,
results: Array<{ id: string; result: unknown }>,
projectId?: string,
) => {
const returnedErrors: {
id: string;
status: number;
message?: string;
error?: string;
}[] = [];
const successes: {
id: string;
status: number;
}[] = [];
errors.forEach((error) => {
if (error.error instanceof InvalidRequestError) {
returnedErrors.push({
id: error.id,
status: 400,
message: "Invalid request data",
error: error.error.message,
});
} else if (error.error instanceof UnauthorizedError) {
returnedErrors.push({
id: error.id,
status: 401,
message: "Authentication error",
error: error.error.message,
});
} else if (error.error instanceof LangfuseNotFoundError) {
returnedErrors.push({
id: error.id,
status: 404,
message: "Resource not found",
error: error.error.message,
});
} else {
returnedErrors.push({
id: error.id,
status: 500,
error: "Internal Server Error",
});
}
});
if (returnedErrors.length > 0) {
traceException(errors);
logger.error("Error processing events", {
errors: returnedErrors,
"langfuse.project.id": projectId,
});
}
results.forEach((result) => {
successes.push({
id: result.id,
status: 201,
});
});
return { successes, errors: returnedErrors };
};
+63 -1
View File
@@ -56,10 +56,66 @@ export const usage = MixedUsage.nullish()
// ensure output is always of new usage model
.pipe(Usage.nullish());
const RawUsageOrCostDetails = z.record(
z.string(),
z.number().nonnegative().nullish(),
);
const OpenAIUsageSchema = z
.object({
prompt_tokens: z.number().nonnegative(),
completion_tokens: z.number().nonnegative(),
total_tokens: z.number().nonnegative(),
prompt_tokens_details: z.record(z.string(), z.number().nonnegative()),
completion_tokens_details: z.record(z.string(), z.number().nonnegative()),
})
.strict()
.transform((v) => {
if (!v) return;
const {
prompt_tokens,
completion_tokens,
total_tokens,
prompt_tokens_details,
completion_tokens_details,
} = v;
const result: z.infer<typeof RawUsageOrCostDetails> & {
input: number;
output: number;
total: number;
} = {
input: prompt_tokens,
output: completion_tokens,
total: total_tokens,
};
if (prompt_tokens_details) {
for (const [key, value] of Object.entries(prompt_tokens_details)) {
result[`input_${key}`] = value;
result.input = Math.max(result.input - (value ?? 0), 0);
}
}
if (completion_tokens_details) {
for (const [key, value] of Object.entries(completion_tokens_details)) {
result[`output_${key}`] = value;
result.output = Math.max(result.output - (value ?? 0), 0);
}
}
return result;
})
.pipe(RawUsageOrCostDetails);
export const UsageOrCostDetails = z
.union([OpenAIUsageSchema, RawUsageOrCostDetails])
.nullish();
export const TraceBody = z.object({
id: z.string().nullish(),
timestamp: stringDateTime,
name: z.string().nullish(),
name: z.string().max(1000).nullish(),
externalId: z.string().nullish(),
input: jsonSchema.nullish(),
output: jsonSchema.nullish(),
@@ -119,6 +175,8 @@ export const CreateGenerationBody = CreateSpanBody.extend({
)
.nullish(),
usage: usage,
usageDetails: UsageOrCostDetails,
costDetails: UsageOrCostDetails,
promptName: z.string().nullish(),
promptVersion: z.number().int().nullish(),
}).refine((value) => {
@@ -147,6 +205,8 @@ export const UpdateGenerationBody = UpdateSpanBody.extend({
)
.nullish(),
usage: usage,
usageDetails: UsageOrCostDetails,
costDetails: UsageOrCostDetails,
promptName: z.string().nullish(),
promptVersion: z.number().int().nullish(),
}).refine((value) => {
@@ -298,6 +358,8 @@ export const LegacyObservationBody = z.object({
input: jsonSchema.nullish(),
output: jsonSchema.nullish(),
usage: usage,
usageDetails: UsageOrCostDetails,
costDetails: UsageOrCostDetails,
metadata: jsonSchema.nullish(),
parentObservationId: z.string().nullish(),
level: z.nativeEnum(ObservationLevel).nullish(),
@@ -17,7 +17,7 @@ type ValidateAndInflateScoreParams = {
export async function validateAndInflateScore(
params: ValidateAndInflateScoreParams,
): Promise<Score> {
const { body, projectId } = params;
const { body, projectId, scoreId } = params;
if (body.configId) {
const config = await prisma.scoreConfig.findFirst({
@@ -32,10 +32,22 @@ export async function validateAndInflateScore(
"The configId you provided does not match a valid config in this project",
);
validateConfigAgainstBody(body, config as ValidatedScoreConfig);
// Override some fields in the score body with config fields
// We ignore the set fields in the body
const bodyWithConfigOverrides = {
...body,
name: config.name,
};
validateConfigAgainstBody(
bodyWithConfigOverrides,
config as ValidatedScoreConfig,
);
return inflateScoreBody({
...params,
projectId,
scoreId,
body: bodyWithConfigOverrides,
config: config as ValidatedScoreConfig,
});
}
@@ -1,9 +1,13 @@
import type { ZodSchema } from "zod";
import { CallbackHandler } from "langfuse-langchain";
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatVertexAI } from "@langchain/google-vertexai";
import { ChatBedrockConverse } from "@langchain/aws";
import {
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
} from "@langchain/core/messages";
@@ -13,15 +17,31 @@ import {
} from "@langchain/core/output_parsers";
import { IterableReadableStream } from "@langchain/core/utils/stream";
import { ChatOpenAI } from "@langchain/openai";
import {
import GCPServiceAccountKeySchema, {
BedrockConfigSchema,
BedrockCredentialSchema,
} from "../../interfaces/customLLMProviderConfigSchemas";
import { AuthHeaderValidVerificationResult } from "../auth/types";
import {
processEventBatch,
type TokenCountDelegate,
} from "../ingestion/processEventBatch";
import { logger } from "../logger";
import { ChatMessage, ChatMessageRole, LLMAdapter, ModelParams } from "./types";
import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
type ProcessTracedEvents = () => Promise<void>;
export type TraceParams = {
traceName: string;
traceId: string;
projectId: string;
tags: string[];
tokenCountDelegate: TokenCountDelegate;
authCheck: AuthHeaderValidVerificationResult;
};
type LLMCompletionParams = {
messages: ChatMessage[];
modelParams: ModelParams;
@@ -31,6 +51,7 @@ type LLMCompletionParams = {
apiKey: string;
maxRetries?: number;
config?: Record<string, string> | null;
traceParams?: TraceParams;
};
type FetchLLMCompletionParams = LLMCompletionParams & {
@@ -40,25 +61,34 @@ type FetchLLMCompletionParams = LLMCompletionParams & {
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: true;
}
): Promise<IterableReadableStream<Uint8Array>>;
},
): Promise<{
completion: IterableReadableStream<Uint8Array>;
processTracedEvents: ProcessTracedEvents;
}>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: false;
}
): Promise<string>;
},
): Promise<{ completion: string; processTracedEvents: ProcessTracedEvents }>;
export async function fetchLLMCompletion(
params: LLMCompletionParams & {
streaming: false;
structuredOutputSchema: ZodSchema;
}
): Promise<unknown>;
},
): Promise<{
completion: unknown;
processTracedEvents: ProcessTracedEvents;
}>;
export async function fetchLLMCompletion(
params: FetchLLMCompletionParams
): Promise<string | IterableReadableStream<Uint8Array> | unknown> {
params: FetchLLMCompletionParams,
): Promise<{
completion: string | IterableReadableStream<Uint8Array> | unknown;
processTracedEvents: ProcessTracedEvents;
}> {
// the apiKey must never be printed to the console
const {
messages,
@@ -69,18 +99,61 @@ export async function fetchLLMCompletion(
baseURL,
maxRetries,
config,
traceParams,
} = params;
const finalMessages = messages.map((message) => {
if (message.role === ChatMessageRole.User)
return new HumanMessage(message.content);
if (message.role === ChatMessageRole.System)
return new SystemMessage(message.content);
let finalCallbacks: BaseCallbackHandler[] | undefined = callbacks ?? [];
let processTracedEvents: ProcessTracedEvents = () => Promise.resolve();
return new AIMessage(message.content);
});
if (traceParams) {
const handler = new CallbackHandler({
_projectId: traceParams.projectId,
_isLocalEventExportEnabled: true,
tags: traceParams.tags,
});
let chatModel: ChatOpenAI | ChatAnthropic | ChatBedrockConverse;
finalCallbacks.push(handler);
processTracedEvents = async () => {
try {
const events = await handler.langfuse._exportLocalEvents(
traceParams.projectId,
);
await processEventBatch(
JSON.parse(JSON.stringify(events)), // stringify to emulate network event batch from network call
traceParams.authCheck,
traceParams.tokenCountDelegate,
);
} catch (e) {
logger.error("Failed to process traced events", { error: e });
}
};
}
finalCallbacks = finalCallbacks.length > 0 ? finalCallbacks : undefined;
let finalMessages: BaseMessage[];
// VertexAI requires at least 1 user message
if (modelParams.adapter === LLMAdapter.VertexAI && messages.length === 1) {
finalMessages = [new HumanMessage(messages[0].content)];
} else {
finalMessages = messages.map((message) => {
if (message.role === ChatMessageRole.User)
return new HumanMessage(message.content);
if (message.role === ChatMessageRole.System)
return new SystemMessage(message.content);
return new AIMessage(message.content);
});
}
finalMessages = finalMessages.filter((m) => m.content.length > 0);
let chatModel:
| ChatOpenAI
| ChatAnthropic
| ChatBedrockConverse
| ChatVertexAI;
if (modelParams.adapter === LLMAdapter.Anthropic) {
chatModel = new ChatAnthropic({
anthropicApiKey: apiKey,
@@ -89,8 +162,8 @@ export async function fetchLLMCompletion(
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
clientOptions: { maxRetries },
callbacks: finalCallbacks,
clientOptions: { maxRetries, timeout: 1000 * 60 * 2 }, // 2 minutes timeout
});
} else if (modelParams.adapter === LLMAdapter.OpenAI) {
chatModel = new ChatOpenAI({
@@ -100,11 +173,12 @@ export async function fetchLLMCompletion(
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
streamUsage: false, // https://github.com/langchain-ai/langchainjs/issues/6533
callbacks,
callbacks: finalCallbacks,
maxRetries,
configuration: {
baseURL,
},
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
} else if (modelParams.adapter === LLMAdapter.Azure) {
chatModel = new ChatOpenAI({
@@ -115,8 +189,9 @@ export async function fetchLLMCompletion(
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
callbacks: finalCallbacks,
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
} else if (modelParams.adapter === LLMAdapter.Bedrock) {
const { region } = BedrockConfigSchema.parse(config);
@@ -129,8 +204,26 @@ export async function fetchLLMCompletion(
temperature: modelParams.temperature,
maxTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks,
callbacks: finalCallbacks,
maxRetries,
timeout: 1000 * 60 * 2, // 2 minutes timeout
});
} else if (modelParams.adapter === LLMAdapter.VertexAI) {
const credentials = GCPServiceAccountKeySchema.parse(JSON.parse(apiKey));
// Requests time out after 60 seconds for both public and private endpoints by default
// Reference: https://cloud.google.com/vertex-ai/docs/predictions/get-online-predictions#send-request
chatModel = new ChatVertexAI({
modelName: modelParams.model,
temperature: modelParams.temperature,
maxOutputTokens: modelParams.max_tokens,
topP: modelParams.top_p,
callbacks: finalCallbacks,
maxRetries,
authOptions: {
projectId: credentials.project_id,
credentials,
},
});
} else {
// eslint-disable-next-line no-unused-vars
@@ -138,10 +231,19 @@ export async function fetchLLMCompletion(
throw new Error("This model provider is not supported.");
}
const runConfig = {
callbacks: finalCallbacks,
runId: traceParams?.traceId,
runName: traceParams?.traceName,
};
if (params.structuredOutputSchema) {
return await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
.withStructuredOutput(params.structuredOutputSchema)
.invoke(finalMessages);
return {
completion: await (chatModel as ChatOpenAI) // Typecast necessary due to https://github.com/langchain-ai/langchainjs/issues/6795
.withStructuredOutput(params.structuredOutputSchema)
.invoke(finalMessages, runConfig),
processTracedEvents,
};
}
/*
@@ -157,27 +259,42 @@ export async function fetchLLMCompletion(
Reference: https://platform.openai.com/docs/guides/reasoning/beta-limitations
*/
if (modelParams.model.startsWith("o1-")) {
return await new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
temperature: 1,
maxTokens: undefined,
topP: undefined,
callbacks,
maxRetries,
configuration: {
baseURL,
},
})
.pipe(new StringOutputParser())
.invoke(
finalMessages.filter((message) => message._getType() !== "system")
);
return {
completion: await new ChatOpenAI({
openAIApiKey: apiKey,
modelName: modelParams.model,
temperature: 1,
maxTokens: undefined,
topP: undefined,
callbacks,
maxRetries,
configuration: {
baseURL,
},
timeout: 1000 * 60 * 2, // 2 minutes timeout
})
.pipe(new StringOutputParser())
.invoke(
finalMessages.filter((message) => message._getType() !== "system"),
runConfig,
),
processTracedEvents,
};
}
if (streaming) {
return chatModel.pipe(new BytesOutputParser()).stream(finalMessages);
return {
completion: await chatModel
.pipe(new BytesOutputParser())
.stream(finalMessages, runConfig),
processTracedEvents,
};
}
return await chatModel.pipe(new StringOutputParser()).invoke(finalMessages);
return {
completion: await chatModel
.pipe(new StringOutputParser())
.invoke(finalMessages, runConfig),
processTracedEvents,
};
}
+34
View File
@@ -16,6 +16,7 @@ export enum LLMAdapter {
OpenAI = "openai",
Azure = "azure",
Bedrock = "bedrock",
VertexAI = "vertex-ai",
}
export enum ChatMessageRole {
@@ -26,6 +27,20 @@ export enum ChatMessageRole {
export const ChatMessageDefaultRoleSchema = z.nativeEnum(ChatMessageRole);
const ChatMessageSchema = z.object({
role: z.union([ChatMessageDefaultRoleSchema, z.string()]), // Users may ingest any string as role via API/SDK
content: z.string(),
});
export const ChatMessageListSchema = z.array(ChatMessageSchema);
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
export const PromptContentSchema = z.union([
ChatMessageListSchema,
TextPromptSchema,
]);
export type PromptContent = z.infer<typeof PromptContentSchema>;
export type ModelParams = {
provider: string;
adapter: LLMAdapter;
@@ -49,6 +64,17 @@ export const ZodModelConfig = z.object({
top_p: z.coerce.number().optional(),
});
// Experiment config
export const ExperimentMetadataSchema = z
.object({
prompt_id: z.string(),
provider: z.string(),
model: z.string(),
model_params: ZodModelConfig,
})
.strict();
export type ExperimentMetadata = z.infer<typeof ExperimentMetadataSchema>;
// NOTE: Update docs page when changing this! https://langfuse.com/docs/playground#openai-playground--anthropic-playground
export const openAIModels = [
"gpt-4o",
@@ -89,10 +115,18 @@ export const anthropicModels = [
"claude-instant-1.2",
] as const;
export const vertexAIModels = [
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.0-pro",
] as const;
export type AnthropicModel = (typeof anthropicModels)[number];
export type VertexAIModel = (typeof vertexAIModels)[number];
export const supportedModels = {
[LLMAdapter.Anthropic]: anthropicModels,
[LLMAdapter.OpenAI]: openAIModels,
[LLMAdapter.VertexAI]: vertexAIModels,
[LLMAdapter.Azure]: [],
[LLMAdapter.Bedrock]: [],
} as const;
@@ -1,20 +1,13 @@
import { filterOperators } from "../../../interfaces/filters";
import { clickhouseCompliantRandomCharacters } from "../../repositories";
function randomCharacters() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let result = "";
const randomArray = new Uint8Array(5);
crypto.getRandomValues(randomArray);
randomArray.forEach((number) => {
result += chars[number % chars.length];
});
return result;
}
export type ClickhouseOperator =
| (typeof filterOperators)[keyof typeof filterOperators][number]
| "!=";
export interface Filter {
apply(): ClickhouseFilter;
clickhouseTable: string;
operator: (typeof filterOperators)[keyof typeof filterOperators][number];
operator: ClickhouseOperator;
field: string;
}
type ClickhouseFilter = {
@@ -44,7 +37,7 @@ export class StringFilter implements Filter {
}
apply(): ClickhouseFilter {
const varName = `stringFilter${randomCharacters()}`;
const varName = `stringFilter${clickhouseCompliantRandomCharacters()}`;
const fieldWithPrefix = `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}`;
let query: string;
@@ -79,29 +72,33 @@ export class NumberFilter implements Filter {
public clickhouseTable: string;
public field: string;
public value: number;
public operator: (typeof filterOperators)["number"][number];
public operator: (typeof filterOperators)["number"][number] | "!=";
public clickhouseTypeOverwrite?: string;
protected tablePrefix?: string;
constructor(opts: {
clickhouseTable: string;
field: string;
operator: (typeof filterOperators)["number"][number];
operator: (typeof filterOperators)["number"][number] | "!=";
value: number;
tablePrefix?: string;
clickhouseTypeOverwrite?: string;
}) {
this.clickhouseTable = opts.clickhouseTable;
this.field = opts.field;
this.value = opts.value;
this.operator = opts.operator;
this.tablePrefix = opts.tablePrefix;
this.clickhouseTypeOverwrite = opts.clickhouseTypeOverwrite;
}
apply(): ClickhouseFilter {
const uid = randomCharacters();
const uid = clickhouseCompliantRandomCharacters();
const varName = `numberFilter${uid}`;
const type = this.clickhouseTypeOverwrite ?? "Decimal64(12)";
return {
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: Decimal}`,
params: { [varName]: this.value },
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: ${type}}`,
params: { [varName]: this.value.toString() },
};
}
}
@@ -128,7 +125,7 @@ export class DateTimeFilter implements Filter {
}
apply(): ClickhouseFilter {
const uid = randomCharacters();
const uid = clickhouseCompliantRandomCharacters();
const varName = `dateTimeFilter${uid}`;
return {
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: DateTime64(3)}`,
@@ -159,13 +156,13 @@ export class StringOptionsFilter implements Filter {
}
apply(): ClickhouseFilter {
const uid = randomCharacters();
const uid = clickhouseCompliantRandomCharacters();
const varName = `stringOptionsFilter${uid}`;
return {
query:
this.operator === "any of"
? `has({${varName}: Array(String)}, ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = True`
: `has({${varName}: Array(String)}, ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = False`,
? `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} IN ({${varName}: Array(String)})`
: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} NOT IN ({${varName}: Array(String)})`,
params: { [varName]: this.values },
};
}
@@ -198,8 +195,8 @@ export class StringObjectFilter implements Filter {
}
apply(): ClickhouseFilter {
const varKeyName = `stringObjectKeyFilter${randomCharacters()}`;
const varValueName = `stringObjectValueFilter${randomCharacters()}`;
const varKeyName = `stringObjectKeyFilter${clickhouseCompliantRandomCharacters()}`;
const varValueName = `stringObjectValueFilter${clickhouseCompliantRandomCharacters()}`;
const column = `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}`;
// const query: `${column}['{varKeyName: String}'] ${this.operator} {${varValueName}: String}`,
@@ -254,7 +251,7 @@ export class ArrayOptionsFilter implements Filter {
}
apply(): ClickhouseFilter {
const uid = randomCharacters();
const uid = clickhouseCompliantRandomCharacters();
const varName = `arrayOptionsFilter${uid}`;
let query: string;
@@ -266,7 +263,7 @@ export class ArrayOptionsFilter implements Filter {
query = `hasAny({${varName}: Array(String)}, ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = False`;
break;
case "all of":
query = `arrayAll(x -> has({${varName}: Array(String)}, x), ${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}) = True`;
query = `hasAll(${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}, {${varName}: Array(String)}) = True`;
break;
default:
throw new Error(`Unsupported operator: ${this.operator}`);
@@ -310,13 +307,13 @@ export class NumberObjectFilter implements Filter {
public field: string;
public key: string;
public value: number;
public operator: (typeof filterOperators)["numberObject"][number];
public operator: (typeof filterOperators)["numberObject"][number] | "!=";
protected tablePrefix?: string;
constructor(opts: {
clickhouseTable: string;
field: string;
operator: (typeof filterOperators)["numberObject"][number];
operator: (typeof filterOperators)["numberObject"][number] | "!=";
key: string;
value: number;
tablePrefix?: string;
@@ -330,11 +327,11 @@ export class NumberObjectFilter implements Filter {
}
apply(): ClickhouseFilter {
const varKeyName = `numberObjectKeyFilter${randomCharacters()}`;
const varValueName = `numberObjectValueFilter${randomCharacters()}`;
const varKeyName = `numberObjectKeyFilter${clickhouseCompliantRandomCharacters()}`;
const varValueName = `numberObjectValueFilter${clickhouseCompliantRandomCharacters()}`;
const column = `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field}`;
return {
query: `empty(arrayFilter(x -> (((x.1) = {${varKeyName}: String}) AND ((x.2) ${this.operator} {${varValueName}: Decimal})), ${column})) = 0`,
query: `empty(arrayFilter(x -> (((x.1) = {${varKeyName}: String}) AND ((x.2) ${this.operator} {${varValueName}: Decimal64(12)})), ${column})) = 0`,
params: { [varKeyName]: this.key, [varValueName]: this.value },
};
}
@@ -362,7 +359,7 @@ export class BooleanFilter implements Filter {
}
apply(): ClickhouseFilter {
const uid = randomCharacters();
const uid = clickhouseCompliantRandomCharacters();
const varName = `booleanFilter${uid}`;
return {
query: `${this.tablePrefix ? this.tablePrefix + "." : ""}${this.field} ${this.operator} {${varName}: Boolean}`,
@@ -374,7 +371,7 @@ export class BooleanFilter implements Filter {
export class FilterList {
private filters: Filter[];
constructor(filters: Filter[]) {
constructor(filters: Filter[] = []) {
this.filters = filters;
}
@@ -386,6 +383,10 @@ export class FilterList {
return this.filters.find(predicate);
}
length() {
return this.filters.length;
}
public apply(): ClickhouseFilter {
if (this.filters.length === 0) {
return {
@@ -67,6 +67,7 @@ export const createFilterFromFilterState = (
operator: frontEndFilter.operator,
value: frontEndFilter.value,
tablePrefix: column.queryPrefix,
clickhouseTypeOverwrite: column.clickhouseTypeOverwrite,
});
case "arrayOptions":
return new ArrayOptionsFilter({
@@ -3,31 +3,53 @@ import { OrderByState } from "../../../interfaces/orderBy";
import { UiColumnMapping } from "../../../tableDefinitions";
import { logger } from "../../logger";
type OrderByStateNotNull = Exclude<OrderByState, null>;
export function orderByToClickhouseSql(
orderBy: OrderByState,
orderBy: OrderByState | OrderByState[] = [],
tableColumns: UiColumnMapping[],
): string {
if (!orderBy) {
if (
!orderBy ||
(Array.isArray(orderBy) && orderBy.filter(Boolean).length === 0)
) {
return "";
}
// Get column definition to map column to internal name, e.g. "t.id"
const col = tableColumns.find(
(c) => c.uiTableName === orderBy.column || c.uiTableId === orderBy.column,
);
if (!col) {
logger.warn("Invalid order by column", orderBy.column);
throw new Error("Invalid order by column: " + orderBy.column);
if (!Array.isArray(orderBy)) {
orderBy = [orderBy];
}
// Initialize an array to hold order by clauses
const orderByClauses: string[] = [];
// Loop through each orderBy entry
for (const ob of orderBy.filter((o): o is OrderByStateNotNull =>
Boolean(o),
)) {
// Get column definition to map column to internal name, e.g. "t.id"
const col = tableColumns.find(
(c) => c.uiTableName === ob.column || c.uiTableId === ob.column,
);
if (!col) {
logger.warn("Invalid order by column", ob.column);
throw new Error("Invalid order by column: " + ob.column);
}
// Assert that ob.order is either "asc" or "desc"
const orderByOrder = z.enum(["ASC", "DESC"]);
const order = orderByOrder.safeParse(ob.order);
if (!order.success) {
logger.warn("Invalid order", ob.order);
throw new Error("Invalid order: " + ob.order);
}
// Append the order by clause to the array
orderByClauses.push(
`${col.queryPrefix ? col.queryPrefix + "." : ""}${col.clickhouseSelect} ${order.data}`,
);
}
// Assert that orderBy.order is either "asc" or "desc"
const orderByOrder = z.enum(["ASC", "DESC"]);
const order = orderByOrder.safeParse(orderBy.order);
if (!order.success) {
logger.warn("Invalid order", orderBy.order);
throw new Error("Invalid order: " + orderBy.order);
}
// Both column and order are safe, can use raw SQL
return `ORDER BY ${col.queryPrefix ? col.queryPrefix + "." : ""}${col.clickhouseSelect} ${order.data}`;
// Join all order by clauses with a comma and return
return `ORDER BY ${orderByClauses.join(", ")}`;
}
@@ -10,6 +10,8 @@ import { TableFilters } from "./types";
type AdditionalObservationFields = {
traceName: string | null;
traceTags: Array<string>;
usageDetails: Record<string, number>;
costDetails: Record<string, number>;
};
export type FullObservation = AdditionalObservationFields & ObservationView;
@@ -11,13 +11,13 @@ export function parseTraceAllFilters(input: TableFilters) {
const filterCondition = tableColumnsToSqlFilterAndPrefix(
input.filter ?? [],
tracesTableCols,
"traces"
"traces",
);
const orderByCondition = orderByToPrismaSql(input.orderBy, tracesTableCols);
// to improve query performance, add timeseries filter to observation queries as well
const timeseriesFilter = input.filter?.find(
(f) => f.column === "Timestamp" && f.type === "datetime"
(f) => f.column === "Timestamp" && f.type === "datetime",
);
const observationTimeseriesFilter =
@@ -25,7 +25,7 @@ export function parseTraceAllFilters(input: TableFilters) {
? datetimeFilterToPrismaSql(
"start_time",
timeseriesFilter.operator,
timeseriesFilter.value
timeseriesFilter.value,
)
: Prisma.empty;
@@ -130,6 +130,6 @@ export function createTracesQuery({
${filterCondition}
${orderByCondition}
${limit ? Prisma.sql`LIMIT ${limit}` : Prisma.empty}
${page && limit ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
${page !== undefined && limit !== undefined ? Prisma.sql`OFFSET ${page * limit}` : Prisma.empty}
`;
}
@@ -7,3 +7,17 @@ export {
type FullObservationsWithScores,
type IOAndMetadataOmittedObservations,
} from "./createGenerationsQuery";
export {
FilterList,
StringFilter,
DateTimeFilter,
StringOptionsFilter,
NumberFilter,
ArrayOptionsFilter,
BooleanFilter,
NumberObjectFilter,
StringObjectFilter,
NullFilter,
type ClickhouseOperator,
} from "./clickhouse-sql/clickhouse-filter";
export { orderByToClickhouseSql } from "./clickhouse-sql/orderby-factory";
+62 -28
View File
@@ -1,14 +1,6 @@
import { z } from "zod";
import { eventTypes, ingestionBatchEvent } from ".";
export enum EventName {
TraceUpsert = "TraceUpsert",
BatchExport = "BatchExport",
EvaluationExecution = "EvaluationExecution",
LegacyIngestion = "LegacyIngestion",
CloudUsageMetering = "CloudUsageMetering",
}
export const LegacyIngestionEventFull = z.object({
useS3EventStore: z.literal(false),
data: ingestionBatchEvent,
@@ -62,10 +54,18 @@ export const BatchExportJobSchema = z.object({
projectId: z.string(),
batchExportId: z.string(),
});
export const TraceUpsertEventSchema = z.object({
export const TraceQueueEventSchema = z.object({
projectId: z.string(),
traceId: z.string(),
});
export const TracesQueueEventSchema = z.object({
projectId: z.string(),
traceIds: z.array(z.string()),
});
export const ProjectQueueEventSchema = z.object({
projectId: z.string(),
orgId: z.string(),
});
export const DatasetRunItemUpsertEventSchema = z.object({
projectId: z.string(),
datasetItemId: z.string(),
@@ -77,60 +77,82 @@ export const EvalExecutionEvent = z.object({
jobExecutionId: z.string(),
delay: z.number().nullish(),
});
export const PostHogIntegrationProcessingEventSchema = z.object({
projectId: z.string(),
});
export const ExperimentCreateEventSchema = z.object({
projectId: z.string(),
datasetId: z.string(),
runId: z.string(),
description: z.string().optional(),
});
export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceUpsertEventType = z.infer<typeof TraceUpsertEventSchema>;
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
export type TracesQueueEventType = z.infer<typeof TracesQueueEventSchema>;
export type ProjectQueueEventType = z.infer<typeof ProjectQueueEventSchema>;
export type DatasetRunItemUpsertEventType = z.infer<
typeof DatasetRunItemUpsertEventSchema
>;
export type EvalExecutionEventType = z.infer<typeof EvalExecutionEvent>;
export type LegacyIngestionEventType = z.infer<typeof LegacyIngestionEvent>;
export type IngestionEventQueueType = z.infer<typeof IngestionEvent>;
export const EventBodySchema = z.union([
z.object({
name: z.literal(EventName.TraceUpsert),
payload: z.array(TraceUpsertEventSchema),
}),
z.object({
name: z.literal(EventName.EvaluationExecution),
payload: EvalExecutionEvent,
}),
z.object({
name: z.literal(EventName.BatchExport),
payload: BatchExportJobSchema,
}),
]);
export type EventBodyType = z.infer<typeof EventBodySchema>;
export type ExperimentCreateEventType = z.infer<
typeof ExperimentCreateEventSchema
>;
export type PostHogIntegrationProcessingEventType = z.infer<
typeof PostHogIntegrationProcessingEventSchema
>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
TraceDelete = "trace-delete",
ProjectDelete = "project-delete",
EvaluationExecution = "evaluation-execution-queue", // Worker executes Evals
DatasetRunItemUpsert = "dataset-run-item-upsert-queue",
BatchExport = "batch-export-queue",
IngestionQueue = "ingestion-queue", // Process single events with S3-merge
LegacyIngestionQueue = "legacy-ingestion-queue", // Used for batch processing of Ingestion
CloudUsageMeteringQueue = "cloud-usage-metering-queue",
ExperimentCreate = "experiment-create-queue",
PostHogIntegrationQueue = "posthog-integration-queue",
PostHogIntegrationProcessingQueue = "posthog-integration-processing-queue",
}
export enum QueueJobs {
TraceUpsert = "trace-upsert",
TraceDelete = "trace-delete",
ProjectDelete = "project-delete",
DatasetRunItemUpsert = "dataset-run-item-upsert",
EvaluationExecution = "evaluation-execution-job",
BatchExportJob = "batch-export-job",
EnqueueBatchExportJobs = "enqueue-batch-export-jobs",
LegacyIngestionJob = "legacy-ingestion-job",
CloudUsageMeteringJob = "cloud-usage-metering-job",
IngestionJob = "ingestion-job",
ExperimentCreateJob = "experiment-create-job",
PostHogIntegrationJob = "posthog-integration-job",
PostHogIntegrationProcessingJob = "posthog-integration-processing-job",
}
export type TQueueJobTypes = {
[QueueName.TraceUpsert]: {
timestamp: Date;
id: string;
payload: TraceUpsertEventType;
payload: TraceQueueEventType;
name: QueueJobs.TraceUpsert;
};
[QueueName.TraceDelete]: {
timestamp: Date;
id: string;
payload: TracesQueueEventType | TraceQueueEventType;
name: QueueJobs.TraceDelete;
};
[QueueName.ProjectDelete]: {
timestamp: Date;
id: string;
payload: ProjectQueueEventType;
name: QueueJobs.ProjectDelete;
};
[QueueName.DatasetRunItemUpsert]: {
timestamp: Date;
id: string;
@@ -161,4 +183,16 @@ export type TQueueJobTypes = {
payload: IngestionEventQueueType;
name: QueueJobs.IngestionJob;
};
[QueueName.ExperimentCreate]: {
timestamp: Date;
id: string;
payload: ExperimentCreateEventType;
name: QueueJobs.ExperimentCreateJob;
};
[QueueName.PostHogIntegrationProcessingQueue]: {
timestamp: Date;
id: string;
payload: PostHogIntegrationProcessingEventType;
name: QueueJobs.PostHogIntegrationProcessingJob;
};
};
@@ -0,0 +1,61 @@
import { Queue } from "bullmq";
import { env } from "../../env";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class CloudUsageMeteringQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (!env.STRIPE_SECRET_KEY) {
return null;
}
if (CloudUsageMeteringQueue.instance) {
return CloudUsageMeteringQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
CloudUsageMeteringQueue.instance = newRedis
? new Queue(QueueName.CloudUsageMeteringQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
CloudUsageMeteringQueue.instance?.on("error", (err) => {
logger.error("CloudUsageMeteringQueue error", err);
});
if (CloudUsageMeteringQueue.instance) {
CloudUsageMeteringQueue.instance.add(
QueueJobs.CloudUsageMeteringJob,
{},
{
repeat: { pattern: "5 * * * *" },
},
);
CloudUsageMeteringQueue.instance.add(
QueueJobs.CloudUsageMeteringJob,
{},
{},
);
}
return CloudUsageMeteringQueue.instance;
}
}
@@ -27,7 +27,8 @@ export class DatasetRunItemUpsertQueue {
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 2,
attempts: 5,
delay: 30_000, // 30 seconds
backoff: {
type: "exponential",
delay: 5000,
@@ -0,0 +1,45 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
export class EvalExecutionQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.EvaluationExecution]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.EvaluationExecution]
> | null {
if (EvalExecutionQueue.instance) return EvalExecutionQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
EvalExecutionQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.EvaluationExecution]>(
QueueName.EvaluationExecution,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 10,
backoff: {
type: "exponential",
delay: 1000,
},
},
},
)
: null;
EvalExecutionQueue.instance?.on("error", (err) => {
logger.error("EvalExecutionQueue error", err);
});
return EvalExecutionQueue.instance;
}
}
@@ -0,0 +1,45 @@
import { Queue } from "bullmq";
import { logger } from "../logger";
import { TQueueJobTypes, QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
export class ExperimentCreateQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.ExperimentCreate]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.ExperimentCreate]
> | null {
if (ExperimentCreateQueue.instance) return ExperimentCreateQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
ExperimentCreateQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.ExperimentCreate]>(
QueueName.ExperimentCreate,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10_000,
attempts: 10,
backoff: {
type: "exponential",
delay: 1000,
},
},
},
)
: null;
ExperimentCreateQueue.instance?.on("error", (err) => {
logger.error("ExperimentCreateQueue error", err);
});
return ExperimentCreateQueue.instance;
}
}
@@ -0,0 +1,46 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { BatchExportQueue } from "./batchExport";
import { CloudUsageMeteringQueue } from "./cloudUsageMeteringQueue";
import { DatasetRunItemUpsertQueue } from "./datasetRunItemUpsert";
import { EvalExecutionQueue } from "./evalExecutionQueue";
import { ExperimentCreateQueue } from "./experimentCreateQueue";
import { IngestionQueue } from "./ingestionQueue";
import { LegacyIngestionQueue } from "./legacyIngestion";
import { TraceUpsertQueue } from "./traceUpsert";
import { TraceDeleteQueue } from "./traceDelete";
import { ProjectDeleteQueue } from "./projectDelete";
import { PostHogIntegrationQueue } from "./postHogIntegrationQueue";
import { PostHogIntegrationProcessingQueue } from "./postHogIntegrationProcessingQueue";
export function getQueue(queueName: QueueName): Queue | null {
switch (queueName) {
case QueueName.LegacyIngestionQueue:
return LegacyIngestionQueue.getInstance();
case QueueName.BatchExport:
return BatchExportQueue.getInstance();
case QueueName.CloudUsageMeteringQueue:
return CloudUsageMeteringQueue.getInstance();
case QueueName.DatasetRunItemUpsert:
return DatasetRunItemUpsertQueue.getInstance();
case QueueName.EvaluationExecution:
return EvalExecutionQueue.getInstance();
case QueueName.ExperimentCreate:
return ExperimentCreateQueue.getInstance();
case QueueName.TraceUpsert:
return TraceUpsertQueue.getInstance();
case QueueName.TraceDelete:
return TraceDeleteQueue.getInstance();
case QueueName.IngestionQueue:
return IngestionQueue.getInstance();
case QueueName.ProjectDelete:
return ProjectDeleteQueue.getInstance();
case QueueName.PostHogIntegrationQueue:
return PostHogIntegrationQueue.getInstance();
case QueueName.PostHogIntegrationProcessingQueue:
return PostHogIntegrationProcessingQueue.getInstance();
default:
const exhaustiveCheckDefault: never = queueName;
throw new Error(`Queue ${queueName} not found`);
}
}
@@ -0,0 +1,40 @@
import { Queue } from "bullmq";
import { QueueName } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationProcessingQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (PostHogIntegrationProcessingQueue.instance) {
return PostHogIntegrationProcessingQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
PostHogIntegrationProcessingQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationProcessingQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
PostHogIntegrationProcessingQueue.instance?.on("error", (err) => {
logger.error("PostHogIntegrationProcessingQueue error", err);
});
return PostHogIntegrationProcessingQueue.instance;
}
}
@@ -0,0 +1,55 @@
import { Queue } from "bullmq";
import { QueueName, QueueJobs } from "../queues";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class PostHogIntegrationQueue {
private static instance: Queue | null = null;
public static getInstance(): Queue | null {
if (PostHogIntegrationQueue.instance) {
return PostHogIntegrationQueue.instance;
}
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
PostHogIntegrationQueue.instance = newRedis
? new Queue(QueueName.PostHogIntegrationQueue, {
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
})
: null;
PostHogIntegrationQueue.instance?.on("error", (err) => {
logger.error("PostHogIntegrationQueue error", err);
});
if (PostHogIntegrationQueue.instance) {
logger.debug("Scheduling jobs for PostHogIntegrationQueue");
PostHogIntegrationQueue.instance
.add(
QueueJobs.PostHogIntegrationJob,
{},
{
repeat: { pattern: "30 * * * *" }, // every hour at 30 minutes past
},
)
.catch((err) => {
logger.error("Error adding PostHogIntegrationJob schedule", err);
});
}
return PostHogIntegrationQueue.instance;
}
}
@@ -0,0 +1,45 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class ProjectDeleteQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.ProjectDelete]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.ProjectDelete]
> | null {
if (ProjectDeleteQueue.instance) return ProjectDeleteQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
ProjectDeleteQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.ProjectDelete]>(
QueueName.ProjectDelete,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
: null;
ProjectDeleteQueue.instance?.on("error", (err) => {
logger.error("ProjectDeleteQueue error", err);
});
return ProjectDeleteQueue.instance;
}
}
@@ -0,0 +1,44 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
export class TraceDeleteQueue {
private static instance: Queue<TQueueJobTypes[QueueName.TraceDelete]> | null =
null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.TraceDelete]
> | null {
if (TraceDeleteQueue.instance) return TraceDeleteQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
TraceDeleteQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.TraceDelete]>(
QueueName.TraceDelete,
{
connection: newRedis,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 5,
backoff: {
type: "exponential",
delay: 5000,
},
},
},
)
: null;
TraceDeleteQueue.instance?.on("error", (err) => {
logger.error("TraceDeleteQueue error", err);
});
return TraceDeleteQueue.instance;
}
}
@@ -1,10 +1,4 @@
import { randomUUID } from "crypto";
import {
QueueJobs,
QueueName,
TQueueJobTypes,
TraceUpsertEventType,
} from "../queues";
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import { logger } from "../logger";
@@ -32,6 +26,7 @@ export class TraceUpsertQueue {
removeOnComplete: 100, // Important: If not true, new jobs for that ID would be ignored as jobs in the complete set are still considered as part of the queue
removeOnFail: 100_000,
attempts: 5,
delay: 15_000, // 15 seconds
backoff: {
type: "exponential",
delay: 5000,
@@ -48,43 +43,3 @@ export class TraceUpsertQueue {
return TraceUpsertQueue.instance;
}
}
export function convertTraceUpsertEventsToRedisEvents(
events: TraceUpsertEventType[],
) {
const uniqueTracesPerProject = events.reduce((acc, event) => {
if (!acc.get(event.projectId)) {
acc.set(event.projectId, new Set());
}
acc.get(event.projectId)?.add(event.traceId);
return acc;
}, new Map<string, Set<string>>());
return [...uniqueTracesPerProject.entries()]
.map((tracesPerProject) => {
const [projectId, traceIds] = tracesPerProject;
return [...traceIds].map((traceId) => ({
name: QueueJobs.TraceUpsert,
data: {
payload: {
projectId,
traceId,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.TraceUpsert as const,
},
opts: {
removeOnFail: 1_000,
removeOnComplete: true,
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
}));
})
.flat();
}
@@ -5,15 +5,19 @@ import {
} from "../clickhouse/client";
import { logger } from "../logger";
import { instrumentAsync } from "../instrumentation";
import { S3StorageService } from "../services/S3StorageService";
import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { randomUUID } from "crypto";
import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
import { NodeClickHouseClientConfigOptions } from "@clickhouse/client/dist/config";
let s3StorageServiceClient: S3StorageService;
let s3StorageServiceClient: StorageService;
const getS3StorageServiceClient = (bucketName: string): S3StorageService => {
const getS3StorageServiceClient = (bucketName: string): StorageService => {
if (!s3StorageServiceClient) {
s3StorageServiceClient = new S3StorageService({
s3StorageServiceClient = StorageServiceFactory.getInstance({
bucketName,
accessKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID,
secretAccessKey: env.LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY,
@@ -28,7 +32,7 @@ const getS3StorageServiceClient = (bucketName: string): S3StorageService => {
export async function upsertClickhouse<
T extends Record<string, unknown>,
>(opts: {
table: "scores" | "traces"; // TODO: Modify eventType logic to support more tables going forward
table: "scores" | "traces" | "observations";
records: T[];
eventBodyMapper: (body: T) => Record<string, unknown>;
}): Promise<void> {
@@ -36,36 +40,33 @@ export async function upsertClickhouse<
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
span.setAttribute("ch.query.table", opts.table);
// drop trailing s and pretend it's always a create.
// Only applicable to scores and traces.
const eventType = `${opts.table.slice(0, -1)}-create`;
const s3Client = getS3StorageServiceClient(
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
);
await Promise.all(
opts.records.map((record) => {
// drop trailing s and pretend it's always a create.
// Only applicable to scores and traces.
let eventType = `${opts.table.slice(0, -1)}-create`;
if (opts.table === "observations") {
// @ts-ignore - If it's an observation we now that `type` is a string
eventType = `${record["type"].toLowerCase()}-create`;
}
s3Client.uploadJson(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${record.project_id}/${getClickhouseEntityType(eventType)}/${record.id}/${randomUUID()}.json`,
[
{
id: randomUUID(),
timestamp: new Date().toISOString(),
type: eventType,
body: opts.eventBodyMapper(record),
},
],
);
}),
);
// If event upload is enabled, we store all rows in S3 to have a backup
if (env.LANGFUSE_S3_EVENT_UPLOAD_ENABLED === "true") {
if (env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET === undefined) {
throw new Error("S3 event store is enabled but no bucket is set");
}
const s3Client = getS3StorageServiceClient(
env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET,
);
await Promise.all(
opts.records.map((record) => {
s3Client.uploadJson(
`${env.LANGFUSE_S3_EVENT_UPLOAD_PREFIX}${record.project_id}/${getClickhouseEntityType(eventType)}/${record.id}/${randomUUID()}.json`,
[
{
id: randomUUID(),
timestamp: new Date().toISOString(),
type: eventType,
body: opts.eventBodyMapper(record),
},
],
);
}),
);
}
const res = await clickhouseClient.insert({
const res = await clickhouseClient().insert({
table: opts.table,
values: opts.records.map((record) => ({
...record,
@@ -103,12 +104,13 @@ export async function upsertClickhouse<
export async function queryClickhouse<T>(opts: {
query: string;
params?: Record<string, unknown> | undefined;
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
}): Promise<T[]> {
return await instrumentAsync({ name: "clickhouse-query" }, async (span) => {
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
span.setAttribute("ch.query.text", opts.query);
const res = await clickhouseClient.query({
const res = await clickhouseClient(opts.clickhouseConfigs).query({
query: opts.query,
format: "JSONEachRow",
query_params: opts.params,
@@ -145,12 +147,12 @@ export async function queryClickhouse<T>(opts: {
export async function commandClickhouse<T>(opts: {
query: string;
params?: Record<string, unknown> | undefined;
clickhouseConfigs?: NodeClickHouseClientConfigOptions;
}): Promise<void> {
return await instrumentAsync({ name: "clickhouse-command" }, async (span) => {
// https://opentelemetry.io/docs/specs/semconv/database/database-spans/
span.setAttribute("ch.query.text", opts.query);
const res = await clickhouseClient.command({
const res = await clickhouseClient(opts.clickhouseConfigs).command({
query: opts.query,
query_params: opts.params,
});
@@ -184,3 +186,14 @@ export async function commandClickhouse<T>(opts: {
export function parseClickhouseUTCDateTimeFormat(dateStr: string): Date {
return new Date(`${dateStr.replace(" ", "T")}Z`);
}
export function clickhouseCompliantRandomCharacters() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let result = "";
const randomArray = new Uint8Array(5);
crypto.getRandomValues(randomArray);
randomArray.forEach((number) => {
result += chars[number % chars.length];
});
return result;
}
@@ -1,4 +1,7 @@
import { queryClickhouse } from "./clickhouse";
import {
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
} from "./clickhouse";
import { createFilterFromFilterState } from "../queries/clickhouse-sql/factory";
import { FilterState } from "../../types";
import {
@@ -200,8 +203,8 @@ export const getObservationUsageByTime = async (
const query = `
SELECT
${selectTimeseriesColumn(groupBy, "start_time", "start_time")},
sumMap(usage_details)['total'] as sum_usage_details,
sumMap(cost_details)['total'] as sum_cost_details,
sumMap(usage_details) as units,
sumMap(cost_details) as cost,
provided_model_name
FROM observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
@@ -214,8 +217,8 @@ export const getObservationUsageByTime = async (
const result = await queryClickhouse<{
start_time: string;
sum_usage_details: string;
sum_cost_details: number;
units: Record<string, number>;
cost: Record<string, number>;
provided_model_name: string;
}>({
query,
@@ -230,8 +233,18 @@ export const getObservationUsageByTime = async (
return result.map((row) => ({
start_time: new Date(row.start_time),
sum_usage_details: Number(row.sum_usage_details),
sum_cost_details: row.sum_cost_details,
units: Object.fromEntries(
Object.entries(row.units ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
cost: Object.fromEntries(
Object.entries(row.cost ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
provided_model_name: row.provided_model_name,
}));
};
@@ -256,8 +269,8 @@ export const getDistinctModels = async (
) as DateTimeFilter | undefined)
: undefined;
// No need for final as duplicates are caught by distinct anyway.
const query = `
// No need for final as duplicates are caught by distinct anyway.
const query = `
SELECT distinct(provided_model_name) as model
FROM observations o
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
@@ -616,6 +629,50 @@ export const getCategoricalScoreTimeSeries = async (
});
};
export const getObservationsStatusTimeSeries = async (
projectId: string,
filter: FilterState,
groupBy: DateTrunc | undefined,
) => {
const chFilter = new FilterList(
createFilterFromFilterState(filter, dashboardColumnDefinitions),
);
const chFilterRes = chFilter.apply();
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const query = `
SELECT
${groupBy ? selectTimeseriesColumn(groupBy, "o.start_time", "start_time_bucket") + ", " : ""}
count(*) as observation_count,
level as level
FROM observations o
${traceFilter ? "JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
WHERE project_id = {projectId: String}
AND o.level IS NOT NULL
AND ${chFilterRes?.query}
GROUP BY level ${groupBy ? ", start_time_bucket" : ""}
${groupBy ? orderByTimeSeries(groupBy, "start_time_bucket") : ""}
`;
const result = await queryClickhouse<{
start_time_bucket?: string;
observation_count: string;
level: string;
}>({
query,
params: { projectId, ...(chFilterRes ? chFilterRes.params : {}) },
});
return result.map((row) => ({
start_time_bucket: row.start_time_bucket
? parseClickhouseUTCDateTimeFormat(row.start_time_bucket)
: undefined,
count: Number(row.observation_count),
level: row.level,
}));
};
const orderByTimeSeries = (dateTrunc: DateTrunc, col: string) => {
let interval;
switch (dateTrunc) {
@@ -129,8 +129,8 @@ export const scoreRecordBaseSchema = z.object({
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
name: z.string().nullish(),
value: z.union([z.number(), z.string()]).nullish(),
name: z.string(),
value: z.number().nullish(),
source: z.string(),
comment: z.string().nullish(),
author_user_id: z.string().nullish(),
@@ -5,3 +5,7 @@ export * from "./types";
export * from "./dashboards";
export * from "./traces_converters";
export * from "./scores_converters";
export * from "./observations_converters";
export * from "./clickhouse";
export * from "./constants";
export * from "./trace-sessions";
@@ -2,8 +2,9 @@ import {
commandClickhouse,
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
upsertClickhouse,
} from "./clickhouse";
import { Observation, ObservationLevel } from "@prisma/client";
import { ObservationLevel } from "@prisma/client";
import { logger } from "../logger";
import { InternalServerError, LangfuseNotFoundError } from "../../errors";
import { prisma } from "../../db";
@@ -37,6 +38,59 @@ import {
OBSERVATIONS_TO_TRACE_INTERVAL,
TRACE_TO_OBSERVATIONS_INTERVAL,
} from "./constants";
import { env } from "../../env";
export const checkObservationExists = async (
projectId: string,
id: string,
startTime: Date | undefined,
): Promise<boolean> => {
const query = `
SELECT id, project_id
FROM observations o
WHERE project_id = {projectId: String}
AND id = {id: String}
${startTime ? `AND start_time >= {startTime: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
`;
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
id,
projectId,
...(startTime
? { startTime: convertDateToClickhouseDateTime(startTime) }
: {}),
},
});
return rows.length > 0;
};
/**
* Accepts a trace in a Clickhouse-ready format.
* id, project_id, and timestamp must always be provided.
*/
export const upsertObservation = async (
observation: Partial<ObservationRecordReadType>,
) => {
if (
!["id", "project_id", "start_time", "type"].every(
(key) => key in observation,
)
) {
throw new Error(
"Identifier fields must be provided to upsert Observation.",
);
}
await upsertClickhouse({
table: "observations",
records: [observation as ObservationRecordReadType],
eventBodyMapper: convertObservation,
});
};
export const getObservationsViewForTrace = async (
traceId: string,
@@ -94,10 +148,98 @@ export const getObservationsViewForTrace = async (
return records.map(convertObservationToView);
};
export const getObservationForTraceIdByName = async (
traceId: string,
projectId: string,
name: string,
timestamp?: Date,
fetchWithInputOutput: boolean = false,
) => {
const query = `
SELECT
id,
trace_id,
project_id,
type,
parent_observation_id,
start_time,
end_time,
name,
metadata,
level,
status_message,
version,
${fetchWithInputOutput ? "input, output," : ""}
provided_model_name,
internal_model_id,
model_parameters,
provided_usage_details,
usage_details,
provided_cost_details,
cost_details,
total_cost,
completion_start_time,
prompt_id,
prompt_name,
prompt_version,
created_at,
updated_at,
event_ts
FROM observations
WHERE trace_id = {traceId: String}
AND project_id = {projectId: String}
AND name = {name: String}
${timestamp ? `AND start_time >= {traceTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id`;
const records = await queryClickhouse<ObservationRecordReadType>({
query,
params: {
traceId,
projectId,
name,
...(timestamp
? { traceTimestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
});
return records.map(convertObservationToView);
};
export const getObservationById = async (
id: string,
projectId: string,
fetchWithInputOutput: boolean = false,
startTime?: Date,
) => {
const records = await getObservationByIdInternal(
id,
projectId,
fetchWithInputOutput,
startTime,
);
const mapped = records.map(convertObservation);
if (mapped.length === 0) {
throw new LangfuseNotFoundError(`Observation with id ${id} not found`);
}
if (mapped.length > 1) {
logger.error(
`Multiple observations found for id ${id} and project ${projectId}`,
);
throw new InternalServerError(
`Multiple observations found for id ${id} and project ${projectId}`,
);
}
return mapped.shift();
};
export const getObservationsById = async (
ids: string[],
projectId: string,
fetchWithInputOutput: boolean = false,
) => {
const query = `
SELECT
@@ -130,16 +272,28 @@ export const getObservationById = async (
updated_at,
event_ts
FROM observations
WHERE id = {id: String}
WHERE id IN ({ids: Array(String)})
AND project_id = {projectId: String}
ORDER BY event_ts desc
LIMIT 1 by id, project_id`;
const records = await queryClickhouse<ObservationRecordReadType>({
query,
params: { id, projectId },
params: { ids, projectId },
});
return records.map(convertObservation);
};
const mapped = records.map(convertObservation);
export const getObservationViewById = async (
id: string,
projectId: string,
fetchWithInputOutput: boolean = false,
) => {
const records = await getObservationByIdInternal(
id,
projectId,
fetchWithInputOutput,
);
const mapped = records.map(convertObservationToView);
if (mapped.length === 0) {
throw new LangfuseNotFoundError(`Observation with id ${id} not found`);
@@ -153,7 +307,61 @@ export const getObservationById = async (
`Multiple observations found for id ${id} and project ${projectId}`,
);
}
return mapped.shift() as Observation;
return mapped.shift();
};
const getObservationByIdInternal = async (
id: string,
projectId: string,
fetchWithInputOutput: boolean = false,
startTime?: Date,
) => {
const query = `
SELECT
id,
trace_id,
project_id,
type,
parent_observation_id,
start_time,
end_time,
name,
metadata,
level,
status_message,
version,
${fetchWithInputOutput ? "input, output," : ""}
provided_model_name,
internal_model_id,
model_parameters,
provided_usage_details,
usage_details,
provided_cost_details,
cost_details,
total_cost,
completion_start_time,
prompt_id,
prompt_name,
prompt_version,
created_at,
updated_at,
event_ts
FROM observations
WHERE id = {id: String}
AND project_id = {projectId: String}
${startTime ? `AND start_time = {startTime: DateTime64(3)}` : ""}
ORDER BY event_ts desc
LIMIT 1 by id, project_id`;
return await queryClickhouse<ObservationRecordReadType>({
query,
params: {
id,
projectId,
...(startTime
? { startTime: convertDateToClickhouseDateTime(startTime) }
: {}),
},
});
};
export type ObservationTableQuery = {
@@ -192,7 +400,7 @@ export type ObservationsTableQueryResult = ObservationRecordReadType & {
export const getObservationsTableCount = async (opts: ObservationTableQuery) =>
getObservationsTableInternal<TableCount>({
...opts,
select: "count(*) as count",
select: "count",
});
export type ObservationsTableRow = Omit<
@@ -210,29 +418,7 @@ export const getObservationsTable = async (
>
>({
...opts,
select: `
o.id as id,
o.name as name,
o."model_parameters" as model_parameters,
o.start_time as "start_time",
o.end_time as "end_time",
o.trace_id as "trace_id",
o.completion_start_time as "completion_start_time",
o.provided_usage_details as "provided_usage_details",
o.usage_details as "usage_details",
o.provided_cost_details as "provided_cost_details",
o.cost_details as "cost_details",
o.level as level,
o.status_message as "status_message",
o.version as version,
o.parent_observation_id as "parent_observation_id",
o.created_at as "created_at",
o.updated_at as "updated_at",
o.provided_model_name as "provided_model_name",
o.total_cost as "total_cost",
internal_model_id as "internal_model_id",
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`,
select: "rows",
});
const traces = await getTracesByIds(
@@ -242,21 +428,19 @@ export const getObservationsTable = async (
opts.projectId,
);
return await Promise.all(
observationRecords.map(async (o) => {
const trace = traces.find((t) => t.id === o.trace_id);
return {
...convertObservationToView({ ...o, type: "GENERATION" }),
latency: o.latency ? Number(o.latency) / 1000 : null,
timeToFirstToken: o.time_to_first_token
? Number(o.time_to_first_token) / 1000
: null,
traceName: trace?.name ?? null,
traceTags: trace?.tags ?? [],
userId: trace?.userId ?? null,
};
}),
);
return observationRecords.map((o) => {
const trace = traces.find((t) => t.id === o.trace_id);
return {
...convertObservationToView({ ...o, type: "GENERATION" }),
latency: o.latency ? Number(o.latency) / 1000 : null,
timeToFirstToken: o.time_to_first_token
? Number(o.time_to_first_token) / 1000
: null,
traceName: trace?.name ?? null,
traceTags: trace?.tags ?? [],
userId: trace?.userId ?? null,
};
});
};
export const getObservationsTableWithModelData = async (
@@ -269,29 +453,7 @@ export const getObservationsTableWithModelData = async (
>
>({
...opts,
select: `
o.id as id,
o.name as name,
o."model_parameters" as model_parameters,
o.start_time as "start_time",
o.end_time as "end_time",
o.trace_id as "trace_id",
o.completion_start_time as "completion_start_time",
o.provided_usage_details as "provided_usage_details",
o.usage_details as "usage_details",
o.provided_cost_details as "provided_cost_details",
o.cost_details as "cost_details",
o.level as level,
o.status_message as "status_message",
o.version as version,
o.parent_observation_id as "parent_observation_id",
o.created_at as "created_at",
o.updated_at as "updated_at",
o.provided_model_name as "provided_model_name",
o.total_cost as "total_cost",
internal_model_id as "internal_model_id",
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`,
select: "rows",
});
const uniqueModels: string[] = Array.from(
@@ -324,43 +486,69 @@ export const getObservationsTableWithModelData = async (
),
]);
return await Promise.all(
observationRecords.map(async (o) => {
const trace = traces.find((t) => t.id === o.trace_id);
const model = models.find((m) => m.id === o.internal_model_id);
return {
...convertObservationToView({ ...o, type: "GENERATION" }),
latency: o.latency ? Number(o.latency) / 1000 : null,
timeToFirstToken: o.time_to_first_token
? Number(o.time_to_first_token) / 1000
: null,
traceName: trace?.name ?? null,
traceTags: trace?.tags ?? [],
userId: trace?.userId ?? null,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ?? null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")?.price ?? null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ?? null,
};
}),
);
return observationRecords.map((o) => {
const trace = traces.find((t) => t.id === o.trace_id);
const model = models.find((m) => m.id === o.internal_model_id);
return {
...convertObservationToView({ ...o, type: "GENERATION" }),
latency: o.latency ? Number(o.latency) / 1000 : null,
timeToFirstToken: o.time_to_first_token
? Number(o.time_to_first_token) / 1000
: null,
traceName: trace?.name ?? null,
traceTags: trace?.tags ?? [],
userId: trace?.userId ?? null,
modelId: model?.id ?? null,
inputPrice:
model?.Price?.find((m) => m.usageType === "input")?.price ?? null,
outputPrice:
model?.Price?.find((m) => m.usageType === "output")?.price ?? null,
totalPrice:
model?.Price?.find((m) => m.usageType === "total")?.price ?? null,
};
});
};
const getObservationsTableInternal = async <T>(
opts: ObservationTableQuery & { select: string },
opts: ObservationTableQuery & { select: "count" | "rows" },
): Promise<Array<T>> => {
const select =
opts.select === "count"
? "count(*) as count"
: `
o.id as id,
o.project_id as "project_id",
o.name as name,
o."model_parameters" as model_parameters,
o.start_time as "start_time",
o.end_time as "end_time",
o.trace_id as "trace_id",
o.completion_start_time as "completion_start_time",
o.provided_usage_details as "provided_usage_details",
o.usage_details as "usage_details",
o.provided_cost_details as "provided_cost_details",
o.cost_details as "cost_details",
o.level as level,
o.status_message as "status_message",
o.version as version,
o.parent_observation_id as "parent_observation_id",
o.created_at as "created_at",
o.updated_at as "updated_at",
o.provided_model_name as "provided_model_name",
o.total_cost as "total_cost",
internal_model_id as "internal_model_id",
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time)) as latency,
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as "time_to_first_token"`;
const { projectId, filter, selectIOAndMetadata, limit, offset, orderBy } =
opts;
const selectString = selectIOAndMetadata
? `
${opts.select},
${select},
${selectIOAndMetadata ? `o.input, o.output, o.metadata` : ""}
`
: opts.select;
: select;
const scoresFilter = new FilterList([
new StringFilter({
@@ -387,6 +575,10 @@ const getObservationsTableInternal = async <T>(
.includes(f.column),
);
const hasScoresFilter = filter.some(
(f) => f.column === "Scores" || f.column === "scores",
);
const orderByTraces = opts.orderBy
? observationsTableTraceUiColumnDefinitions
.map((c) => c.uiTableId)
@@ -457,63 +649,58 @@ const getObservationsTableInternal = async <T>(
observation_id
)`;
if (traceTableFilter.length > 0 || orderByTraces) {
// joins with traces are very expensive. We need to filter by time as well.
// We assume that a trace has to have been within the last 2 days to be relevant.
// if we have default ordering by time, we order by toDate(o.start_time) first and then by
// o.start_time. This way, clickhouse is able to read more efficiently directly from disk without ordering
const newDefaultOrder =
orderBy?.column === "startTime"
? [{ column: "order_by_date", order: orderBy.order }, orderBy]
: [orderBy ?? null];
const query = `
const chOrderBy = orderByToClickhouseSql(newDefaultOrder, [
...observationsTableUiColumnDefinitions,
{
uiTableName: "order_by_date",
uiTableId: "order_by_date",
clickhouseTableName: "observation",
clickhouseSelect: "toDate(o.start_time)",
},
]);
// joins with traces are very expensive. We need to filter by time as well.
// We assume that a trace has to have been within the last 2 days to be relevant.
const query = `
${scoresCte}
SELECT
${selectString}
FROM observations o FINAL
LEFT JOIN traces t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = o.trace_id and s_avg.observation_id = o.id
FROM observations o
${traceTableFilter.length > 0 || orderByTraces || search.query ? "LEFT JOIN traces t FINAL ON t.id = o.trace_id AND t.project_id = o.project_id" : ""}
${hasScoresFilter ? `LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = o.trace_id and s_avg.observation_id = o.id` : ""}
WHERE ${appliedObservationsFilter.query}
AND o.type = 'GENERATION'
${timeFilter ? `AND t.timestamp > {tracesTimestampFilter: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
${timeFilter && (traceTableFilter.length > 0 || orderByTraces) ? `AND t.timestamp > {tracesTimestampFilter: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
${search.query}
${orderByToClickhouseSql(orderBy ?? null, observationsTableUiColumnDefinitions)}
${chOrderBy}
${opts.select === "rows" ? "LIMIT 1 BY o.id, o.project_id" : ""}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
...appliedScoresFilter.params,
...appliedObservationsFilter.params,
...(timeFilter
? {
tracesTimestampFilter: convertDateToClickhouseDateTime(
timeFilter.value as Date,
),
}
: {}),
...search.params,
},
});
const res = await queryClickhouse<T>({
query,
params: {
...appliedScoresFilter.params,
...appliedObservationsFilter.params,
...(timeFilter
? {
tracesTimestampFilter: convertDateToClickhouseDateTime(
timeFilter.value as Date,
),
}
: {}),
...search.params,
},
});
return res;
} else {
// we query by T, which could also be {count: string}.
const query = `
${scoresCte}
SELECT
${selectString}
FROM observations o FINAL
LEFT JOIN scores_avg AS s_avg ON s_avg.trace_id = o.trace_id and s_avg.observation_id = o.id
WHERE ${appliedObservationsFilter.query}
${orderByToClickhouseSql(orderBy ?? null, observationsTableUiColumnDefinitions)}
${limit !== undefined && offset !== undefined ? `LIMIT ${limit} OFFSET ${offset}` : ""};`;
const res = await queryClickhouse<T>({
query,
params: {
...appliedScoresFilter.params,
...appliedObservationsFilter.params,
},
});
return res;
}
return res;
};
export const getObservationsGroupedByModel = async (
@@ -714,6 +901,25 @@ export const deleteObservationsByTraceIds = async (
projectId,
traceIds,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
export const deleteObservationsByProjectId = async (projectId: string) => {
const query = `
DELETE FROM observations
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
@@ -771,8 +977,8 @@ export const getObservationMetricsForPrompts = async (
prompt_version,
min(start_time) AS first_observation,
max(start_time) AS last_observation,
medianExact(usage_details['input']) AS median_input_usage,
medianExact(usage_details['output']) AS median_output_usage,
medianExact(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, usage_details)))) AS median_input_usage,
medianExact(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details)))) AS median_output_usage,
medianExact(cost_details['total']) AS median_total_cost,
medianExact(latency_ms) AS median_latency_ms
FROM latencies
@@ -876,3 +1082,165 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
latency: Number(r.latency_ms) / 1000,
}));
};
export const getObservationCountsByProjectInCreationInterval = async ({
start,
end,
}: {
start: Date;
end: Date;
}) => {
const query = `
SELECT
project_id,
count(*) as count
FROM observations
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>({
query,
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
},
});
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
};
export const getObservationCountOfProjectsSinceCreationDate = async ({
projectIds,
start,
}: {
projectIds: string[];
start: Date;
}) => {
const query = `
SELECT
count(*) as count
FROM observations
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: {
projectIds,
start: convertDateToClickhouseDateTime(start),
},
});
return Number(rows[0]?.count ?? 0);
};
export const getTraceIdsForObservations = async (
projectId: string,
observationIds: string[],
) => {
const query = `
SELECT
trace_id,
id
FROM observations
WHERE project_id = {projectId: String}
AND id IN ({observationIds: Array(String)})
`;
const rows = await queryClickhouse<{ id: string; trace_id: string }>({
query,
params: {
projectId,
observationIds,
},
});
return rows.map((row) => ({
id: row.id,
traceId: row.trace_id,
}));
};
export const getGenerationsForPostHog = async (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) => {
const query = `
SELECT
o.name as name,
o.start_time as start_time,
o.id as id,
o.total_cost as total_cost,
if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time)) as time_to_first_token,
o.usage_details['total'] as input_tokens,
o.usage_details['output'] as output_tokens,
o.cost_details['total'] as total_tokens,
o.project_id as project_id,
if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000) as latency,
o.provided_model_name as model,
o.level as level,
o.version as version,
t.id as trace_id,
t.name as trace_name,
t.session_id as trace_session_id,
t.user_id as trace_user_id,
t.release as trace_release,
t.tags as trace_tags,
t.metadata['$posthog_session_id'] as posthog_session_id
FROM observations o FINAL
LEFT JOIN traces t FINAL ON o.trace_id = t.id AND o.project_id = t.project_id
WHERE o.project_id = {projectId: String}
AND t.project_id = {projectId: String}
AND o.start_time >= {minTimestamp: DateTime64(3)}
AND o.start_time <= {maxTimestamp: DateTime64(3)}
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
AND o.type = 'GENERATION'
`;
const records = await queryClickhouse<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
return records.map((record) => ({
timestamp: record.start_time,
langfuse_generation_name: record.name,
langfuse_trace_name: record.trace_name,
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.trace_id as string)}?observation=${encodeURIComponent(record.id as string)}`,
langfuse_id: record.id,
langfuse_cost_usd: record.total_cost,
langfuse_input_units: record.input_tokens,
langfuse_output_units: record.output_tokens,
langfuse_total_units: record.total_tokens,
langfuse_session_id: record.trace_session_id,
langfuse_project_id: projectId,
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
langfuse_latency: record.latency,
langfuse_time_to_first_token: record.time_to_first_token,
langfuse_release: record.trace_release,
langfuse_version: record.version,
langfuse_model: record.model,
langfuse_level: record.level,
langfuse_tags: record.trace_tags,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
langfuse_user_url: record.user_id
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
: null,
},
}));
};
@@ -3,36 +3,54 @@ import {
ObservationView,
ObservationType,
ObservationLevel,
Prisma,
} from "@prisma/client";
import Decimal from "decimal.js";
import { jsonSchema } from "../../utils/zod";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { ObservationRecordReadType } from "./definitions";
import { parseJsonPrioritised } from "../../utils/json";
import { jsonSchema } from "../../utils/zod";
export const convertObservationToView = (
record: ObservationRecordReadType,
): Omit<
ObservationView,
"inputPrice" | "outputPrice" | "totalPrice" | "modelId"
> => {
): Omit<ObservationView, "inputPrice" | "outputPrice" | "totalPrice"> & {
usageDetails: Record<string, number>;
costDetails: Record<string, number>;
} => {
// these cost are not used from the view. They are in the select statement but not in the
// Prisma file. We will not clean this up but keep it as it is for now.
// eslint-disable-next-line no-unused-vars
const { inputCost, outputCost, totalCost, internalModelId, ...rest } =
convertObservation(record ?? undefined);
return {
...convertObservation(record ?? undefined),
...rest,
latency: record.end_time
? parseClickhouseUTCDateTimeFormat(record.end_time).getTime() -
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
: null,
timeToFirstToken: record.completion_start_time
? parseClickhouseUTCDateTimeFormat(record.start_time).getTime() -
parseClickhouseUTCDateTimeFormat(record.completion_start_time).getTime()
: null,
promptName: record.prompt_name ?? null,
promptVersion: record.prompt_version ?? null,
modelId: record.internal_model_id ?? null,
};
};
export const convertObservation = (
record: ObservationRecordReadType,
): Omit<Observation, "internalModel"> => {
): Omit<Observation, "internalModel"> & {
promptName: string | null;
promptVersion: number | null;
latency: number | null;
timeToFirstToken: number | null;
usageDetails: Record<string, number>;
costDetails: Record<string, number>;
} => {
const reducedUsageDetails = reduceUsageOrCostDetails(record.usage_details);
const reducedCostDetails = reduceUsageOrCostDetails(record.cost_details);
const reducedProvidedCostDetails = reduceUsageOrCostDetails(
record.provided_cost_details,
);
return {
id: record.id,
traceId: record.trace_id ?? null,
@@ -48,8 +66,12 @@ export const convertObservation = (
level: record.level as ObservationLevel,
statusMessage: record.status_message ?? null,
version: record.version ?? null,
input: jsonSchema.nullish().parse(record.input) ?? null,
output: jsonSchema.nullish().parse(record.output) ?? null,
input: (record.input
? jsonSchema.parse(parseJsonPrioritised(record.input))
: null) as Prisma.JsonValue | null,
output: (record.output
? jsonSchema.parse(parseJsonPrioritised(record.output))
: null) as Prisma.JsonValue | null,
modelParameters: record.model_parameters
? JSON.parse(record.model_parameters)
: null,
@@ -59,33 +81,79 @@ export const convertObservation = (
promptId: record.prompt_id ?? null,
createdAt: parseClickhouseUTCDateTimeFormat(record.created_at),
updatedAt: parseClickhouseUTCDateTimeFormat(record.updated_at),
promptTokens: record.usage_details?.input
? Number(record.usage_details?.input)
: 0,
completionTokens: record.usage_details?.output
? Number(record.usage_details?.output)
: 0,
totalTokens: record.usage_details?.total
? Number(record.usage_details?.total)
: 0,
calculatedInputCost: record.cost_details?.input
? new Decimal(record.cost_details.input)
: null,
calculatedOutputCost: record.cost_details?.output
? new Decimal(record.cost_details.output)
: null,
promptTokens: reducedUsageDetails.input ?? 0,
completionTokens: reducedUsageDetails.output ?? 0,
totalTokens: reducedUsageDetails.total ?? 0,
calculatedInputCost:
reducedCostDetails.input != null
? new Decimal(reducedCostDetails.input)
: null,
calculatedOutputCost:
reducedCostDetails.output != null
? new Decimal(reducedCostDetails.output)
: null,
calculatedTotalCost: record.cost_details?.total
? new Decimal(record.cost_details.total)
: null,
inputCost: record.cost_details?.input
? new Decimal(record.cost_details?.input)
: null,
outputCost: record.cost_details?.output
? new Decimal(record.cost_details?.output)
: null,
inputCost:
reducedProvidedCostDetails.input != null
? new Decimal(reducedProvidedCostDetails.input)
: null,
outputCost:
reducedProvidedCostDetails.output != null
? new Decimal(reducedProvidedCostDetails.output)
: null,
totalCost: record.total_cost ? new Decimal(record.total_cost) : null,
usageDetails: Object.fromEntries(
Object.entries(record.usage_details ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
costDetails: Object.fromEntries(
Object.entries(record.cost_details ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
model: record.provided_model_name ?? null,
internalModelId: record.internal_model_id ?? null,
unit: "TOKENS", // to be removed.
promptName: record.prompt_name ?? null,
promptVersion: record.prompt_version ?? null,
latency: record.end_time
? parseClickhouseUTCDateTimeFormat(record.end_time).getTime() -
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
: null,
timeToFirstToken: record.completion_start_time
? parseClickhouseUTCDateTimeFormat(
record.completion_start_time,
).getTime() -
parseClickhouseUTCDateTimeFormat(record.start_time).getTime()
: null,
};
};
export const reduceUsageOrCostDetails = (
details: Record<string, number> | null | undefined,
): {
input: number | null;
output: number | null;
total: number | null;
} => {
return {
input: Object.entries(details ?? {})
.filter(([usageType]) => usageType.startsWith("input"))
.reduce(
(acc, [_, value]) => (acc ?? 0) + Number(value),
null as number | null, // default to null if no input usage is found
),
output: Object.entries(details ?? {})
.filter(([usageType]) => usageType.startsWith("output"))
.reduce(
(acc, [_, value]) => (acc ?? 0) + Number(value),
null as number | null, // default to null if no output usage is found
),
total: Number(details?.total ?? 0),
};
};
+184 -29
View File
@@ -6,7 +6,7 @@ import {
upsertClickhouse,
} from "./clickhouse";
import { FilterList } from "../queries/clickhouse-sql/clickhouse-filter";
import { FilterState } from "../../types";
import { FilterCondition, FilterState, TimeFilter } from "../../types";
import {
createFilterFromFilterState,
getProjectIdDefaultFilter,
@@ -24,28 +24,8 @@ import {
} from "./scores_converters";
import { SCORE_TO_TRACE_OBSERVATIONS_INTERVAL } from "./constants";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
export type FetchScoresReturnType = {
id: string;
timestamp: string;
project_id: string;
trace_id: string;
observation_id: string | null;
name: string;
value: number;
source: string;
comment: string | null;
author_user_id: string | null;
config_id: string | null;
data_type: string;
string_value: string | null;
queue_id: string | null;
created_at: string;
updated_at: string;
event_ts: string;
is_deleted: number;
projectId: string;
};
import { ScoreRecordReadType } from "./definitions";
import { env } from "../../env";
export const searchExistingAnnotationScore = async (
projectId: string,
@@ -74,7 +54,7 @@ export const searchExistingAnnotationScore = async (
LIMIT 1
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query,
params: {
projectId,
@@ -103,7 +83,7 @@ export const getScoreById = async (
LIMIT 1
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query,
params: {
projectId,
@@ -114,17 +94,43 @@ export const getScoreById = async (
return rows.map(convertToScore).shift();
};
export const getScoresByIds = async (
projectId: string,
scoreId: string[],
source?: ScoreSource,
) => {
const query = `
SELECT *
FROM scores s
WHERE s.project_id = {projectId: String}
AND s.id IN ({scoreId: Array(String)})
${source ? `AND s.source = {source: String}` : ""}
ORDER BY s.event_ts DESC
LIMIT 1 BY s.id, s.project_id
`;
const rows = await queryClickhouse<ScoreRecordReadType>({
query,
params: {
projectId,
scoreId,
...(source !== undefined ? { source } : {}),
},
});
return rows.map(convertToScore);
};
/**
* Accepts a score in a Clickhouse-ready format.
* id, project_id, name, and timestamp must always be provided.
*/
export const upsertScore = async (score: Partial<FetchScoresReturnType>) => {
export const upsertScore = async (score: Partial<ScoreRecordReadType>) => {
if (!["id", "project_id", "name", "timestamp"].every((key) => key in score)) {
throw new Error("Identifier fields must be provided to upsert Score.");
}
await upsertClickhouse({
table: "scores",
records: [score as FetchScoresReturnType],
records: [score as ScoreRecordReadType],
eventBodyMapper: convertToScore,
});
};
@@ -148,7 +154,7 @@ export const getScoresForTraces = async (
${limit && offset ? `limit {limit: Int32} offset {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query: query,
params: {
projectId,
@@ -181,7 +187,7 @@ export const getScoresForObservations = async (
${limit !== undefined && offset !== undefined ? `limit {limit: Int32} offset {offset: Int32}` : ""}
`;
const rows = await queryClickhouse<FetchScoresReturnType>({
const rows = await queryClickhouse<ScoreRecordReadType>({
query: query,
params: {
projectId: projectId,
@@ -497,6 +503,25 @@ export const deleteScoresByTraceIds = async (
projectId,
traceIds,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
export const deleteScoresByProjectId = async (projectId: string) => {
const query = `
DELETE FROM scores
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
@@ -510,10 +535,14 @@ export const getNumericScoreHistogram = async (
);
const chFilterRes = chFilter.apply();
const traceFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const query = `
select s.value
from scores s
${traceFilter ? `LEFT JOIN traces t ON s.trace_id = t.id AND t.project_id = s.project_id` : ""}
WHERE s.project_id = {projectId: String}
${traceFilter ? `AND t.project_id = {projectId: String}` : ""}
${chFilterRes?.query ? `AND ${chFilterRes.query}` : ""}
ORDER BY s.event_ts DESC
LIMIT 1 BY s.id, s.project_id
@@ -570,3 +599,129 @@ export const getAggregatedScoresForPrompts = async (
promptId: row.prompt_id,
}));
};
export const getScoreCountsByProjectInCreationInterval = async ({
start,
end,
}: {
start: Date;
end: Date;
}) => {
const query = `
SELECT
project_id,
count(*) as count
FROM scores
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>({
query,
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
},
});
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
};
export const getDistinctScoreNames = async (
projectId: string,
cutoffCreatedAt: Date,
filter: FilterState,
isTimestampFilter: (filter: FilterCondition) => filter is TimeFilter,
) => {
const scoreTimestampFilter = filter?.find(isTimestampFilter);
const query = `
SELECT DISTINCT
name
FROM scores s
WHERE s.project_id = {projectId: String}
AND s.created_at <= {cutoffCreatedAt: DateTime64(3)}
${scoreTimestampFilter ? `AND s.timestamp >= {filterTimestamp: DateTime64(3)}` : ""}
`;
const rows = await queryClickhouse<{ name: string }>({
query,
params: {
projectId,
cutoffCreatedAt: convertDateToClickhouseDateTime(cutoffCreatedAt),
...(scoreTimestampFilter
? {
filterTimestamp: convertDateToClickhouseDateTime(
scoreTimestampFilter.value,
),
}
: {}),
},
});
return rows.map((row) => row.name);
};
export const getScoresForPostHog = async (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) => {
const query = `
SELECT
s.id as id,
s.timestamp as timestamp,
s.name as name,
s.value as value,
s.comment as comment,
t.name as trace_name,
t.session_id as trace_session_id,
t.user_id as trace_user_id,
t.release as trace_release,
t.tags as trace_tags,
t.metadata['$posthog_session_id'] as posthog_session_id
FROM scores s FINAL
LEFT JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
WHERE s.project_id = {projectId: String}
AND t.project_id = {projectId: String}
AND s.timestamp >= {minTimestamp: DateTime64(3)}
AND s.timestamp <= {maxTimestamp: DateTime64(3)}
AND t.timestamp >= {minTimestamp: DateTime64(3)} - INTERVAL 7 DAY
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = await queryClickhouse<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
return records.map((record) => ({
timestamp: record.timestamp,
langfuse_score_name: record.name,
langfuse_score_value: record.value,
langfuse_score_comment: record.comment,
langfuse_trace_name: record.trace_name,
langfuse_id: record.id,
langfuse_session_id: record.trace_session_id,
langfuse_project_id: projectId,
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
langfuse_release: record.trace_release,
langfuse_tags: record.trace_tags,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
langfuse_user_url: record.user_id
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
: null,
},
}));
};
@@ -1,5 +1,5 @@
import { ScoreSource, ScoreDataType } from "@prisma/client";
import { FetchScoresReturnType } from "./scores";
import { ScoreSource, ScoreDataType, Score } from "@prisma/client";
import { ScoreRecordReadType } from "./definitions";
export type ScoreAggregation = {
id: string;
@@ -11,22 +11,22 @@ export type ScoreAggregation = {
comment: string | null;
};
export const convertToScore = (row: FetchScoresReturnType) => {
export const convertToScore = (row: ScoreRecordReadType): Score => {
return {
id: row.id,
timestamp: new Date(row.timestamp),
projectId: row.project_id,
traceId: row.trace_id,
observationId: row.observation_id,
observationId: row.observation_id ?? null,
name: row.name,
value: row.value,
value: row.value ?? null,
source: row.source as ScoreSource,
comment: row.comment,
authorUserId: row.author_user_id,
configId: row.config_id,
comment: row.comment ?? null,
authorUserId: row.author_user_id ?? null,
configId: row.config_id ?? null,
dataType: row.data_type as ScoreDataType,
stringValue: row.string_value,
queueId: row.queue_id,
stringValue: row.string_value ?? null,
queueId: row.queue_id ?? null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
@@ -0,0 +1,61 @@
import z from "zod";
import { prisma } from "../../db";
import { singleFilter, stringOptionsFilter } from "../../interfaces/filters";
export const getPublicSessionsFilter = async (
projectId: string,
filter: z.infer<typeof singleFilter>[],
) => {
const sessionsBookmarkedFilter = filter?.find((f) => f.column === "⭐️");
// we are only fetching bookmarked sessions.
// They need to be manipulated in the UI and should not be as many.
const filteredSessions = sessionsBookmarkedFilter
? await prisma.traceSession.findMany({
where: {
projectId: projectId,
bookmarked: true,
},
select: {
id: true,
createdAt: true,
bookmarked: true,
public: true,
},
})
: [];
const additionalBookmarkFilter: z.infer<typeof stringOptionsFilter>[] =
sessionsBookmarkedFilter &&
typeof sessionsBookmarkedFilter.value === "boolean" &&
((sessionsBookmarkedFilter.value === true &&
sessionsBookmarkedFilter.operator === "=") ||
(sessionsBookmarkedFilter.value === false &&
sessionsBookmarkedFilter.operator === "<>"))
? [
{
column: "id",
type: "stringOptions" as const,
operator: "any of" as const,
value: filteredSessions.map((s) => s.id),
},
]
: sessionsBookmarkedFilter &&
typeof sessionsBookmarkedFilter.value === "boolean" &&
(sessionsBookmarkedFilter.value === false ||
(sessionsBookmarkedFilter.value === true &&
sessionsBookmarkedFilter.operator === "<>"))
? [
{
column: "id",
type: "stringOptions" as const,
operator: "none of" as const,
value: filteredSessions.map((s) => s.id),
},
]
: [];
return filter
? [...filter.filter((f) => f.column !== "⭐️"), ...additionalBookmarkFilter]
: [...additionalBookmarkFilter];
};
+321 -277
View File
@@ -8,14 +8,11 @@ import {
createFilterFromFilterState,
getProjectIdDefaultFilter,
} from "../queries/clickhouse-sql/factory";
import { ObservationLevel, Trace } from "@prisma/client";
import { FilterState } from "../../types";
import { logger } from "../logger";
import {
DateTimeFilter,
FilterList,
StringFilter,
StringOptionsFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import { TraceRecordReadType } from "./definitions";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions/mapTracesTable";
@@ -24,241 +21,86 @@ import { orderByToClickhouseSql } from "../queries/clickhouse-sql/orderby-factor
import { UiColumnMapping } from "../../tableDefinitions";
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import {
convertClickhouseToDomain,
convertToDomain,
} from "./traces_converters";
import { convertClickhouseToDomain } from "./traces_converters";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import { TRACE_TO_OBSERVATIONS_INTERVAL } from "./constants";
import {
OBSERVATIONS_TO_TRACE_INTERVAL,
TRACE_TO_OBSERVATIONS_INTERVAL,
} from "./constants";
import { env } from "../../env";
export type TracesTableReturnType = Pick<
TraceRecordReadType,
| "project_id"
| "id"
| "name"
| "timestamp"
| "bookmarked"
| "release"
| "version"
| "user_id"
| "session_id"
| "tags"
| "public"
> & {
level: ObservationLevel;
observation_count: number | null;
latency_milliseconds: string | null;
usage_details: Record<string, number>;
cost_details: Record<string, number>;
scores_avg: Array<{ name: string; avg_value: number }>;
};
export const getTracesTableCount = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
offset?: number;
}) => {
const countRows = await getTracesTableGeneric<{ count: string }>({
select: "count(*) as count",
...props,
});
const converted = countRows.map((row) => ({
count: Number(row.count),
}));
return converted.length > 0 ? converted[0].count : 0;
};
export const getTracesTable = async (
export const checkTraceExists = async (
projectId: string,
traceId: string,
timestamp: Date | undefined,
filter: FilterState,
searchQuery?: string,
orderBy?: OrderByState,
limit?: number,
offset?: number,
) => {
const rows = await getTracesTableGeneric<TracesTableReturnType>({
select: `
t.id,
t.project_id as project_id,
t.timestamp,
t.tags,
t.bookmarked,
t.name,
t.release,
t.version,
t.user_id,
t.session_id,
os.latency_milliseconds,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.level as level,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
t.public`,
projectId,
filter,
searchQuery,
orderBy,
limit,
offset,
): Promise<boolean> => {
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
tracesPrefix: "t",
});
return rows.map(convertToDomain);
};
type FetchTracesTableProps = {
select: string;
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
offset?: number;
};
const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
const { select, projectId, filter, orderBy, limit, offset, searchQuery } =
props;
const { tracesFilter, scoresFilter, observationsFilter } =
getProjectIdDefaultFilter(projectId, { tracesPrefix: "t" });
tracesFilter.push(
...createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
);
const traceIdFilter = tracesFilter.find(
(f) => f.clickhouseTable === "traces" && f.field === "id",
) as StringFilter | StringOptionsFilter | undefined;
traceIdFilter
? scoresFilter.push(
new StringOptionsFilter({
clickhouseTable: "scores",
field: "trace_id",
operator: "any of",
values:
traceIdFilter instanceof StringFilter
? [traceIdFilter.value]
: traceIdFilter.values,
}),
)
: null;
traceIdFilter
? observationsFilter.push(
new StringOptionsFilter({
clickhouseTable: "observations",
field: "trace_id",
operator: "any of",
values:
traceIdFilter instanceof StringFilter
? [traceIdFilter.value]
: traceIdFilter.values,
}),
)
: null;
// for query optimisation, we have to add the timeseries filter to observations + scores as well
// stats show, that 98% of all observations have their start_time larger than trace.timestamp - 5 min
const timeStampFilter = tracesFilter.find(
(f) =>
f.field === "timestamp" && (f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined;
timeStampFilter
? scoresFilter.push(
new DateTimeFilter({
clickhouseTable: "scores",
field: "timestamp",
operator: ">=",
value: timeStampFilter.value,
}),
)
: null;
timeStampFilter
? observationsFilter.push(
new DateTimeFilter({
clickhouseTable: "observations",
field: "start_time",
operator: ">=",
value: timeStampFilter.value,
}),
)
: null;
tracesFilter.push(
...createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
new StringFilter({
clickhouseTable: "t",
field: "id",
operator: "=",
value: traceId,
}),
);
const observationFilter = tracesFilter.find(
(f) => f.clickhouseTable === "observations",
);
const tracesFilterRes = tracesFilter.apply();
const scoresFilterRes = scoresFilter.apply();
const observationFilterRes = observationsFilter.apply();
const search = clickhouseSearchCondition(searchQuery);
const observationFilterRes = observationFilter?.apply();
const query = `
WITH observations_stats AS (
SELECT
COUNT(*) AS observation_count,
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS level,
sumMap(cost_details) as cost_details,
trace_id,
project_id
FROM observations FINAL
WHERE ${observationFilterRes.query}
GROUP BY trace_id, project_id
),
scores_avg AS (
SELECT
project_id,
trace_id,
groupArray(tuple(name, avg_value)) AS "scores_avg"
FROM (
SELECT project_id,
trace_id,
name,
avg(value) avg_value
FROM scores final
WHERE ${scoresFilterRes.query}
GROUP BY project_id,
trace_id,
name
) tmp
GROUP BY project_id, trace_id
)
SELECT ${select}
FROM traces t final
LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id
LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id
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 level,
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}` : ""}
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}
${search.query}
${orderByToClickhouseSql(orderBy ?? null, tracesTableUiColumnDefinitions)}
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
AND t.project_id = {projectId: String}
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY t.id, t.project_id
`;
const res = await queryClickhouse<T>({
query: query,
const rows = await queryClickhouse<{ id: string; project_id: string }>({
query,
params: {
limit: limit,
offset: offset,
projectId,
...tracesFilterRes.params,
...observationFilterRes.params,
...scoresFilterRes.params,
...search.params,
...(observationFilterRes ? observationFilterRes.params : {}),
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
});
return res;
return rows.length > 0;
};
/**
@@ -344,6 +186,37 @@ export const hasAnyTrace = async (projectId: string) => {
return rows.length > 0 && Number(rows[0].count) > 0;
};
export const getTraceCountsByProjectInCreationInterval = async ({
start,
end,
}: {
start: Date;
end: Date;
}) => {
const query = `
SELECT
project_id,
count(*) as count
FROM traces
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>({
query,
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
},
});
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
};
export const getTraceById = async (
traceId: string,
projectId: string,
@@ -535,7 +408,7 @@ export const getSessionsTableCount = async (props: {
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
offset?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<{ count: string }>({
select: `
@@ -545,7 +418,7 @@ export const getSessionsTableCount = async (props: {
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
offset: props.offset,
page: props.page,
});
return rows.length > 0 ? Number(rows[0].count) : 0;
@@ -556,7 +429,7 @@ export const getSessionsTable = async (props: {
filter: FilterState;
orderBy?: OrderByState;
limit?: number;
offset?: number;
page?: number;
}) => {
const rows = await getSessionsTableGeneric<SessionDataReturnType>({
select: `
@@ -582,14 +455,24 @@ export const getSessionsTable = async (props: {
filter: props.filter,
orderBy: props.orderBy,
limit: props.limit,
offset: props.offset,
page: props.page,
});
return rows;
};
const getSessionsTableGeneric = async <T>(props: FetchTracesTableProps) => {
const { select, projectId, filter, orderBy, limit, offset } = props;
export type FetchSessionsTableProps = {
select: string;
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
};
const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
const { select, projectId, filter, orderBy, limit, page } = props;
const { tracesFilter, scoresFilter, observationsFilter } =
getProjectIdDefaultFilter(projectId, { tracesPrefix: "s" });
@@ -646,11 +529,11 @@ const getSessionsTableGeneric = async <T>(props: FetchTracesTableProps) => {
date_diff('milliseconds', min(min_start_time), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
sumMap(o.sum_cost_details)['input'] as session_input_cost,
sumMap(o.sum_cost_details)['output'] as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
sumMap(o.sum_usage_details)['input'] as session_input_usage,
sumMap(o.sum_usage_details)['output'] as session_output_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage
FROM traces t FINAL
LEFT JOIN observations_agg o
@@ -664,7 +547,7 @@ const getSessionsTableGeneric = async <T>(props: FetchTracesTableProps) => {
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const obsStartTimeValue = traceTimestampFilter
@@ -676,7 +559,7 @@ const getSessionsTableGeneric = async <T>(props: FetchTracesTableProps) => {
params: {
projectId,
limit: limit,
offset: offset,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...observationsStatsRes.params,
...scoresAvgFilterRes.params,
@@ -741,6 +624,25 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
projectId,
traceIds,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
export const deleteTracesByProjectId = async (projectId: string) => {
const query = `
DELETE FROM traces
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
},
clickhouseConfigs: {
request_timeout: 120_000, // 2 minutes
},
});
};
@@ -782,59 +684,95 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
if (userIds.length === 0) {
return [];
}
// this query uses window functions on observations + traces to always get only the first row and thereby remove deduplicates
// we filter wherever possible by project id and user id
const query = `
WITH observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
sumMap(usage_details) as sum_usage_details,
sum(total_cost) as sum_total_cost,
anyLast(project_id) as project_id
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
GROUP BY o.trace_id
),
user_metric_data AS (
SELECT t.user_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
count(*) as trace_count,
sum(o.obs_count) as total_observations,
sum(o.sum_total_cost) as session_total_cost,
sumMap(o.sum_usage_details)['input'] as session_input_usage,
sumMap(o.sum_usage_details)['output'] as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage
FROM traces t FINAL
LEFT JOIN observations_agg o
ON t.id = o.trace_id
AND t.project_id = o.project_id
WHERE t.user_id IS NOT NULL
AND t.user_id != ''
AND t.user_id IN ({userIds: Array(String)})
AND t.project_id = {projectId: String}
GROUP BY t.user_id
WITH stats as (
SELECT
t.user_id as user_id,
count(distinct o.id) as obs_count,
sumMap(usage_details) as sum_usage_details,
sum(total_cost) as sum_total_cost,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
count(distinct t.id) as trace_count
FROM
(
SELECT
o.project_id,
o.trace_id,
o.usage_details,
o.total_cost,
id,
ROW_NUMBER() OVER (
PARTITION BY id
ORDER BY
event_ts DESC
) AS rn
FROM
observations o
WHERE
o.project_id = {projectId: String }
AND o.trace_id in (
SELECT
distinct id
from
traces
where
user_id IN ({userIds: Array(String) })
AND project_id = {projectId: String }
)
AND o.type = 'GENERATION'
) as o
JOIN (
SELECT
t.id,
t.user_id,
t.project_id,
t.timestamp,
ROW_NUMBER() OVER (
PARTITION BY id
ORDER BY
event_ts DESC
) AS rn
FROM
traces t
WHERE
t.user_id IN ({userIds: Array(String) })
AND t.project_id = {projectId: String }
) as t on t.id = o.trace_id
and t.project_id = o.project_id
WHERE
o.rn = 1
and t.rn = 1
group by
t.user_id
)
SELECT user_id AS userId,
min_timestamp as firstTrace,
max_timestamp as lastTrace,
trace_count as totalTraces,
total_observations as totalObservations,
session_input_usage as totalPromptTokens,
session_output_usage as totalCompletionTokens,
session_total_usage as totalTokens,
session_total_cost as sumCalculatedTotalCost
FROM user_metric_data umd
SELECT
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sum_usage_details))) as input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sum_usage_details))) as output_usage,
sum_usage_details [ 'total' ] as total_usage,
obs_count,
trace_count,
user_id,
sum_total_cost,
max_timestamp,
min_timestamp
FROM
stats
`;
return queryClickhouse<{
userId: string;
firstTrace: Date | null;
lastTrace: Date | null;
totalPromptTokens: bigint;
totalCompletionTokens: bigint;
totalTokens: bigint;
totalObservations: bigint;
totalTraces: bigint;
sumCalculatedTotalCost: number;
const rows = await queryClickhouse<{
user_id: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query,
params: {
@@ -842,4 +780,110 @@ export const getUserMetrics = async (projectId: string, userIds: string[]) => {
userIds,
},
});
return rows.map((row) => ({
userId: row.user_id,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
};
export const getTracesForPostHog = async (
projectId: string,
minTimestamp: Date,
maxTimestamp: Date,
) => {
const query = `
WITH observations_agg AS (
SELECT o.project_id,
o.trace_id,
sum(total_cost) as total_cost,
count(*) as observation_count,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds
FROM observations o FINAL
WHERE o.project_id = {projectId: String}
AND o.start_time >= {minTimestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
GROUP BY o.project_id, o.trace_id
)
SELECT
t.id as id,
t.timestamp as timestamp,
t.name as name,
t.session_id as session_id,
t.user_id as user_id,
t.release as release,
t.version as version,
t.tags as tags,
t.metadata['$posthog_session_id'] as posthog_session_id,
o.total_cost as total_cost,
o.latency_milliseconds / 1000 as latency,
o.observation_count as observation_count
FROM traces t FINAL
LEFT JOIN observations_agg o ON t.id = o.trace_id AND t.project_id = o.project_id
WHERE t.project_id = {projectId: String}
AND t.timestamp >= {minTimestamp: DateTime64(3)}
AND t.timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = await queryClickhouse<Record<string, unknown>>({
query,
params: {
projectId,
minTimestamp: convertDateToClickhouseDateTime(minTimestamp),
maxTimestamp: convertDateToClickhouseDateTime(maxTimestamp),
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
return records.map((record) => ({
timestamp: record.timestamp,
langfuse_id: record.id,
langfuse_trace_name: record.name,
langfuse_url: `${baseUrl}/project/${projectId}/traces/${encodeURIComponent(record.id as string)}`,
langfuse_cost_usd: record.total_cost,
langfuse_count_observations: record.observation_count,
langfuse_session_id: record.session_id,
langfuse_project_id: projectId,
langfuse_user_id: record.user_id || "langfuse_unknown_user",
langfuse_latency: record.latency,
langfuse_release: record.release,
langfuse_version: record.version,
langfuse_tags: record.tags,
langfuse_event_version: "1.0.0",
$session_id: record.posthog_session_id ?? null,
$set: {
langfuse_user_url: record.user_id
? `${baseUrl}/project/${projectId}/users/${encodeURIComponent(record.user_id as string)}`
: null,
},
}));
};
export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
const query = `
SELECT id, project_id
FROM traces
WHERE id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: {
traceIds,
},
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
};
@@ -1,9 +1,6 @@
import { ObservationLevel, Trace } from "@prisma/client";
import { Trace } from "@prisma/client";
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
import { TraceRecordReadType } from "./definitions";
import { TracesTableReturnType } from "./traces";
import Decimal from "decimal.js";
import { ScoreAggregate } from "../../features/scores";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
export const convertTraceDomainToClickhouse = (
@@ -54,53 +51,3 @@ export const convertClickhouseToDomain = (
externalId: null,
};
};
export type TracesAllReturnType = {
id: string;
timestamp: Date;
name: string | null;
projectId: string;
userId: string | null;
release: string | null;
version: string | null;
public: boolean;
bookmarked: boolean;
sessionId: string | null;
tags: string[];
};
export const convertToDomain = (row: TracesTableReturnType) => {
return {
id: row.id,
projectId: row.project_id,
timestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
tags: row.tags,
bookmarked: row.bookmarked,
name: row.name ?? null,
release: row.release ?? null,
version: row.version ?? null,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
latencyMilliseconds: Number(row.latency_milliseconds),
usageDetails: row.usage_details,
costDetails: row.cost_details,
level: row.level,
observationCount: Number(row.observation_count),
scoresAvg: row.scores_avg,
public: row.public,
};
};
export type TracesMetricsReturnType = {
id: string;
promptTokens: bigint;
completionTokens: bigint;
totalTokens: bigint;
latency: number | null;
level: ObservationLevel;
observationCount: bigint;
calculatedTotalCost: Decimal | null;
calculatedInputCost: Decimal | null;
calculatedOutputCost: Decimal | null;
scores: ScoreAggregate;
};
@@ -1,172 +0,0 @@
import type { Readable } from "stream";
import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { logger } from "../logger";
type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
expiresInSeconds: number;
};
export class S3StorageService {
private client: S3Client;
private bucketName: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const { accessKeyId, secretAccessKey } = params;
const credentials =
accessKeyId !== undefined && secretAccessKey !== undefined
? {
accessKeyId,
secretAccessKey,
}
: undefined;
this.client = new S3Client({
credentials,
endpoint: params.endpoint,
region: params.region,
forcePathStyle: params.forcePathStyle,
});
this.bucketName = params.bucketName;
}
public async uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
public async uploadJson(path: string, body: Record<string, unknown>[]) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
try {
await this.client.send(putCommand);
} catch (err) {
logger.error(`Failed to upload JSON to S3 ${path}`, err);
throw Error("Failed to upload JSON to S3");
}
}
public async download(path: string): Promise<string> {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: path,
});
try {
const response = await this.client.send(getCommand);
return (await response.Body?.transformToString()) ?? "";
} catch (err) {
logger.error(`Failed to download file from S3 ${path}`, err);
throw Error("Failed to download file from S3");
}
}
public async listFiles(prefix: string): Promise<string[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
});
try {
const response = await this.client.send(listCommand);
return (
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
);
} catch (err) {
logger.error(`Failed to list files from S3 ${prefix}`, err);
throw Error("Failed to list files from S3");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment: boolean = true,
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: this.bucketName,
Key: fileName,
ResponseContentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
}),
{ expiresIn: ttlSeconds },
);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
throw Error("Failed to generate signed URL");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
return await getSignedUrl(
this.client,
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
{
expiresIn: ttlSeconds,
signableHeaders: new Set(["content-type", "content-length"]),
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
},
);
}
}
@@ -0,0 +1,432 @@
import { Readable } from "stream";
import {
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
BlobSASPermissions,
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import { logger } from "../logger";
import { env } from "../../env";
type UploadFile = {
fileName: string;
fileType: string;
data: Readable | string;
expiresInSeconds: number;
};
export interface StorageService {
uploadFile(params: UploadFile): Promise<{ signedUrl: string }>;
uploadJson(path: string, body: Record<string, unknown>[]): Promise<void>;
download(path: string): Promise<string>;
listFiles(prefix: string): Promise<string[]>;
getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
): Promise<string>;
getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string>;
}
export class StorageServiceFactory {
public static getInstance(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}): StorageService {
if (env.LANGFUSE_USE_AZURE_BLOB === "true") {
return new AzureBlobStorageService(params);
}
return new S3StorageService(params);
}
}
class AzureBlobStorageService implements StorageService {
private client: ContainerClient;
private container: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
const { accessKeyId, secretAccessKey, endpoint } = params;
if (!accessKeyId || !secretAccessKey || !endpoint) {
throw new Error(
`Endpoint, account and account key must be configured to use Azure Blob Storage`,
);
}
const sharedKeyCredential = new StorageSharedKeyCredential(
accessKeyId,
secretAccessKey,
);
const blobServiceClient = new BlobServiceClient(
endpoint,
sharedKeyCredential,
);
this.container = params.bucketName;
this.client = blobServiceClient.getContainerClient(this.container);
}
private async createContainerIfNotExists(): Promise<void> {
try {
await this.client.createIfNotExists();
} catch (err) {
logger.error(
`Failed to create Azure Blob Storage container ${this.container}`,
err,
);
throw Error("Failed to create Azure Blob Storage container ");
}
}
public async uploadFile(params: UploadFile): Promise<{ signedUrl: string }> {
const { fileName, data, expiresInSeconds } = params;
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(fileName);
if (typeof data === "string") {
await blockBlobClient.upload(data, data.length);
} else if (data instanceof Readable) {
let offset = 0;
const blockIds = [];
for await (const chunk of data) {
const blockId = Buffer.from(`block-${offset}`).toString("base64");
const bufferChunk = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
await blockBlobClient.stageBlock(
blockId,
bufferChunk,
bufferChunk.length,
);
blockIds.push(blockId);
offset += bufferChunk.length;
}
await blockBlobClient.commitBlockList(blockIds);
} else {
throw new Error("Unsupported data type. Must be Readable or string.");
}
return {
signedUrl: await this.getSignedUrl(fileName, expiresInSeconds, false),
};
} catch (err) {
logger.error(
`Failed to upload file to Azure Blob Storage ${fileName}`,
err,
);
throw Error("Failed to upload file to Azure Blob Storage");
}
}
public async uploadJson(
path: string,
body: Record<string, unknown>[],
): Promise<void> {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(path);
const content = JSON.stringify(body);
try {
await blockBlobClient.upload(content, content.length);
} catch (err) {
logger.error(`Failed to upload JSON to Azure Blob Storage ${path}`, err);
throw Error("Failed to upload JSON to Azure Blob Storage");
}
}
private async streamToString(
readableStream: NodeJS.ReadableStream,
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
readableStream.on("data", (data) => {
chunks.push(data.toString());
});
readableStream.on("end", () => {
resolve(chunks.join(""));
});
readableStream.on("error", reject);
});
}
public async download(path: string): Promise<string> {
try {
await this.createContainerIfNotExists();
const blobClient = this.client.getBlobClient(path);
const downloadResponse = await blobClient.download();
if (!downloadResponse.readableStreamBody) {
throw Error("No stream body available");
}
return this.streamToString(downloadResponse.readableStreamBody);
} catch (err) {
logger.error(
`Failed to download file from Azure Blob Storage ${path}`,
err,
);
throw Error("Failed to download file from Azure Blob Storage");
}
}
public async listFiles(prefix: string): Promise<string[]> {
try {
await this.createContainerIfNotExists();
const result = await this.client.listBlobsFlat({ prefix });
const files = [];
for await (const blob of result) {
if (blob.name.startsWith(prefix)) {
files.push(blob.name);
}
}
return files;
} catch (err) {
logger.error(
`Failed to list files from Azure Blob Storage ${prefix}`,
err,
);
throw Error("Failed to list files from Azure Blob Storage");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment?: boolean,
): Promise<string> {
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(fileName);
return blockBlobClient.generateSasUrl({
permissions: BlobSASPermissions.parse("r"),
expiresOn: new Date(Date.now() + ttlSeconds * 1000),
contentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
});
} catch (err) {
logger.error(
`Failed to generate presigned URL for Azure Blob Storage ${fileName}`,
err,
);
throw Error("Failed to generate presigned URL for Azure Blob Storage");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType } = params;
try {
await this.createContainerIfNotExists();
const blockBlobClient = this.client.getBlockBlobClient(path);
return blockBlobClient.generateSasUrl({
permissions: BlobSASPermissions.parse("w"),
expiresOn: new Date(Date.now() + ttlSeconds * 1000),
contentType: contentType,
});
} catch (err) {
logger.error(
`Failed to generate presigned upload URL for Azure Blob Storage ${path}`,
err,
);
throw Error(
"Failed to generate presigned upload URL for Azure Blob Storage",
);
}
}
}
class S3StorageService implements StorageService {
private client: S3Client;
private bucketName: string;
constructor(params: {
accessKeyId: string | undefined;
secretAccessKey: string | undefined;
bucketName: string;
endpoint: string | undefined;
region: string | undefined;
forcePathStyle: boolean;
}) {
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
const { accessKeyId, secretAccessKey } = params;
const credentials =
accessKeyId !== undefined && secretAccessKey !== undefined
? {
accessKeyId,
secretAccessKey,
}
: undefined;
this.client = new S3Client({
credentials,
endpoint: params.endpoint,
region: params.region,
forcePathStyle: params.forcePathStyle,
});
this.bucketName = params.bucketName;
}
public async uploadFile({
fileName,
fileType,
data,
expiresInSeconds,
}: UploadFile): Promise<{ signedUrl: string }> {
try {
await new Upload({
client: this.client,
params: {
Bucket: this.bucketName,
Key: fileName,
Body: data,
ContentType: fileType,
},
}).done();
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
return { signedUrl };
} catch (err) {
logger.error(`Failed to upload file to ${fileName}`, err);
throw new Error("Failed to upload to S3 or generate signed URL");
}
}
public async uploadJson(path: string, body: Record<string, unknown>[]) {
const putCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
Body: JSON.stringify(body),
ContentType: "application/json",
});
try {
await this.client.send(putCommand);
} catch (err) {
logger.error(`Failed to upload JSON to S3 ${path}`, err);
throw Error("Failed to upload JSON to S3");
}
}
public async download(path: string): Promise<string> {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: path,
});
try {
const response = await this.client.send(getCommand);
return (await response.Body?.transformToString()) ?? "";
} catch (err) {
logger.error(`Failed to download file from S3 ${path}`, err);
throw Error("Failed to download file from S3");
}
}
public async listFiles(prefix: string): Promise<string[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
});
try {
const response = await this.client.send(listCommand);
return (
response.Contents?.flatMap((file) => (file.Key ? [file.Key] : [])) ?? []
);
} catch (err) {
logger.error(`Failed to list files from S3 ${prefix}`, err);
throw Error("Failed to list files from S3");
}
}
public async getSignedUrl(
fileName: string,
ttlSeconds: number,
asAttachment: boolean = true,
): Promise<string> {
try {
return await getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: this.bucketName,
Key: fileName,
ResponseContentDisposition: asAttachment
? `attachment; filename="${fileName}"`
: undefined,
}),
{ expiresIn: ttlSeconds },
);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
throw Error("Failed to generate signed URL");
}
}
public async getSignedUploadUrl(params: {
path: string;
ttlSeconds: number;
sha256Hash: string;
contentType: string;
contentLength: number;
}): Promise<string> {
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
return await getSignedUrl(
this.client,
new PutObjectCommand({
Bucket: this.bucketName,
Key: path,
ContentType: contentType,
ChecksumSHA256: sha256Hash,
ContentLength: contentLength,
}),
{
expiresIn: ttlSeconds,
signableHeaders: new Set(["content-type", "content-length"]),
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
},
);
}
}
@@ -0,0 +1,420 @@
import { ObservationLevel } from "@prisma/client";
import { OrderByState } from "../../interfaces/orderBy";
import { tracesTableUiColumnDefinitions } from "../../tableDefinitions";
import { FilterState } from "../../types";
import {
StringFilter,
StringOptionsFilter,
DateTimeFilter,
} from "../queries/clickhouse-sql/clickhouse-filter";
import {
getProjectIdDefaultFilter,
createFilterFromFilterState,
} from "../queries/clickhouse-sql/factory";
import { orderByToClickhouseSql } from "../queries/clickhouse-sql/orderby-factory";
import { clickhouseSearchCondition } from "../queries/clickhouse-sql/search";
import {
parseClickhouseUTCDateTimeFormat,
queryClickhouse,
} from "../repositories/clickhouse";
import { TraceRecordReadType } from "../repositories/definitions";
import {
OBSERVATIONS_TO_TRACE_INTERVAL,
SCORE_TO_TRACE_OBSERVATIONS_INTERVAL,
} from "../repositories/constants";
import Decimal from "decimal.js";
import { ScoreAggregate } from "../../features/scores";
import { reduceUsageOrCostDetails } from "../repositories";
export type TracesTableReturnType = Pick<
TraceRecordReadType,
| "project_id"
| "id"
| "name"
| "timestamp"
| "bookmarked"
| "release"
| "version"
| "user_id"
| "session_id"
| "tags"
| "public"
>;
export type TracesAllUiReturnType = {
id: string;
timestamp: Date;
name: string | null;
projectId: string;
userId: string | null;
release: string | null;
version: string | null;
public: boolean;
bookmarked: boolean;
sessionId: string | null;
tags: string[];
};
export type TracesMetricsUiReturnType = {
id: string;
projectId: string;
promptTokens: bigint;
completionTokens: bigint;
totalTokens: bigint;
latency: number | null;
level: ObservationLevel;
observationCount: bigint;
calculatedTotalCost: Decimal | null;
calculatedInputCost: Decimal | null;
calculatedOutputCost: Decimal | null;
scores: ScoreAggregate;
usageDetails: Record<string, number>;
costDetails: Record<string, number>;
};
export const convertToUiTableRows = (
row: TracesTableReturnType,
): TracesAllUiReturnType => {
return {
id: row.id,
projectId: row.project_id,
timestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
tags: row.tags,
bookmarked: row.bookmarked,
name: row.name ?? null,
release: row.release ?? null,
version: row.version ?? null,
userId: row.user_id ?? null,
sessionId: row.session_id ?? null,
public: row.public,
};
};
export const convertToUITableMetrics = (
row: TracesTableMetricsClickhouseReturnType,
): Omit<TracesMetricsUiReturnType, "scores"> => {
const usageDetails = reduceUsageOrCostDetails(row.usage_details);
return {
id: row.id,
projectId: row.project_id,
latency: Number(row.latency),
promptTokens: BigInt(usageDetails.input ?? 0),
completionTokens: BigInt(usageDetails.output ?? 0),
totalTokens: BigInt(usageDetails.total ?? 0),
usageDetails: Object.fromEntries(
Object.entries(row.usage_details).map(([key, value]) => [
key,
Number(value),
]),
),
costDetails: Object.fromEntries(
Object.entries(row.cost_details).map(([key, value]) => [
key,
Number(value),
]),
),
observationCount: BigInt(row.observation_count ?? 0),
calculatedTotalCost: row.cost_details?.total
? new Decimal(row.cost_details.total)
: null,
calculatedInputCost: row.cost_details?.input
? new Decimal(row.cost_details.input)
: null,
calculatedOutputCost: row.cost_details?.output
? new Decimal(row.cost_details.output)
: null,
level: row.level,
};
};
export type TracesTableMetricsClickhouseReturnType = {
id: string;
project_id: string;
timestamp: Date;
level: ObservationLevel;
observation_count: number | null;
latency: string | null;
usage_details: Record<string, number>;
cost_details: Record<string, number>;
scores_avg: Array<{ name: string; avg_value: number }>;
};
export type FetchTracesTableProps = {
select: "count" | "rows" | "metrics";
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
};
export const getTracesTableCount = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
}) => {
const countRows = await getTracesTableGeneric<{ count: string }>({
select: "count",
...props,
});
const converted = countRows.map((row) => ({
count: Number(row.count),
}));
return converted.length > 0 ? converted[0].count : 0;
};
export const getTracesTableMetrics = async (props: {
projectId: string;
filter: FilterState;
searchQuery?: string;
orderBy?: OrderByState;
limit?: number;
page?: number;
}): Promise<Array<Omit<TracesMetricsUiReturnType, "scores">>> => {
const countRows =
await getTracesTableGeneric<TracesTableMetricsClickhouseReturnType>({
select: "metrics",
...props,
});
return countRows.map(convertToUITableMetrics);
};
export const getTracesTable = async (
projectId: string,
filter: FilterState,
searchQuery?: string,
orderBy?: OrderByState,
limit?: number,
page?: number,
) => {
const rows = await getTracesTableGeneric<TracesTableReturnType>({
select: "rows",
projectId,
filter,
searchQuery,
orderBy,
limit,
page,
});
return rows.map(convertToUiTableRows);
};
const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
const { select, projectId, filter, orderBy, limit, page, searchQuery } =
props;
let sqlSelect: string;
switch (select) {
case "count":
sqlSelect = "count(*) as count";
break;
case "metrics":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
os.latency_milliseconds / 1000 as latency,
os.cost_details as cost_details,
os.usage_details as usage_details,
os.level as level,
os.observation_count as observation_count,
s.scores_avg as scores_avg,
t.public as public`;
break;
case "rows":
sqlSelect = `
t.id as id,
t.project_id as project_id,
t.timestamp as timestamp,
t.tags as tags,
t.bookmarked as bookmarked,
t.name as name,
t.release as release,
t.version as version,
t.user_id as user_id,
t.session_id as session_id,
t.public as public`;
break;
default:
const exhaustiveCheckDefault: never = select;
throw new Error(`Unknown select type: ${select}`);
}
const { tracesFilter, scoresFilter, observationsFilter } =
getProjectIdDefaultFilter(projectId, { tracesPrefix: "t" });
tracesFilter.push(
...createFilterFromFilterState(filter, tracesTableUiColumnDefinitions),
);
const traceIdFilter = tracesFilter.find(
(f) => f.clickhouseTable === "traces" && f.field === "id",
) as StringFilter | StringOptionsFilter | undefined;
traceIdFilter
? scoresFilter.push(
new StringOptionsFilter({
clickhouseTable: "scores",
field: "trace_id",
operator: "any of",
values:
traceIdFilter instanceof StringFilter
? [traceIdFilter.value]
: traceIdFilter.values,
}),
)
: null;
traceIdFilter
? observationsFilter.push(
new StringOptionsFilter({
clickhouseTable: "observations",
field: "trace_id",
operator: "any of",
values:
traceIdFilter instanceof StringFilter
? [traceIdFilter.value]
: traceIdFilter.values,
}),
)
: null;
// for query optimisation, we have to add the timeseries filter to observations + scores as well
// stats show, that 98% of all observations have their start_time larger than trace.timestamp - 5 min
const timeStampFilter = tracesFilter.find(
(f) =>
f.field === "timestamp" && (f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined;
const requiresScoresJoin =
tracesFilter.find((f) => f.clickhouseTable === "scores") !== undefined ||
tracesTableUiColumnDefinitions.find(
(c) =>
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
)?.clickhouseTableName === "scores";
const requiresObservationsJoin =
tracesFilter.find((f) => f.clickhouseTable === "observations") !==
undefined ||
tracesTableUiColumnDefinitions.find(
(c) =>
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
)?.clickhouseTableName === "observations";
const tracesFilterRes = tracesFilter.apply();
const scoresFilterRes = scoresFilter.apply();
const observationFilterRes = observationsFilter.apply();
const search = clickhouseSearchCondition(searchQuery);
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
const orderByCols = [
...tracesTableUiColumnDefinitions,
{
clickhouseSelect: "toDate(t.timestamp)",
uiTableName: "timestamp_to_date",
uiTableId: "timestamp_to_date",
clickhouseTableName: "observations",
},
];
const chOrderBy = orderByToClickhouseSql(
[
defaultOrder
? {
column: "timestamp_to_date",
order: orderBy.order,
}
: null,
orderBy ?? null,
],
orderByCols,
);
// complex query ahead:
// - we only join scores and observations if we really need them to speed up default views
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
// - we filter the observations and scores as much as possible before joining them to traces.
// - we order by todate(timestamp), timestamp per default and do not use FINAL.
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
const query = `
WITH observations_stats AS (
SELECT
COUNT(*) AS observation_count,
sumMap(usage_details) as usage_details,
SUM(total_cost) AS total_cost,
date_diff('milliseconds', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
multiIf(
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
'DEBUG'
) AS level,
sumMap(cost_details) as cost_details,
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}` : ""}
${observationsFilter ? `AND ${observationFilterRes.query}` : ""}
GROUP BY trace_id, project_id
),
scores_avg AS (
SELECT
project_id,
trace_id,
groupArray(tuple(name, avg_value)) AS "scores_avg"
FROM (
SELECT project_id,
trace_id,
name,
avg(value) avg_value
FROM scores s FINAL
WHERE project_id = {projectId: String}
${timeStampFilter ? `AND s.timestamp >= {traceTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""}
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
GROUP BY project_id,
trace_id,
name
) tmp
GROUP BY project_id, trace_id
)
SELECT ${sqlSelect}
-- FINAL is used for non default ordering and count.
FROM traces t ${["metrics", "rows"].includes(select) && defaultOrder ? "" : "FINAL"}
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
WHERE t.project_id = {projectId: String}
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
${search.query}
${chOrderBy}
-- This is used for metrics and row queries. Count has only one result.
-- This is only used for default ordering. Otherwise, we use final.
${["metrics", "rows"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const res = await queryClickhouse<T>({
query: query,
params: {
limit: limit,
offset: limit && page ? limit * page : 0,
traceTimestamp: timeStampFilter?.value.getTime(),
projectId: projectId,
...tracesFilterRes.params,
...observationFilterRes.params,
...scoresFilterRes.params,
...search.params,
},
});
return res;
};
@@ -0,0 +1,32 @@
import { clickhouseClient } from "../clickhouse/client";
import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
} from "../repositories/definitions";
export const createTracesCh = async (trace: TraceRecordInsertType[]) => {
return await clickhouseClient().insert({
table: "traces",
format: "JSONEachRow",
values: trace,
});
};
export const createObservationsCh = async (
observations: ObservationRecordInsertType[],
) => {
return await clickhouseClient().insert({
table: "observations",
format: "JSONEachRow",
values: observations,
});
};
export const createScoresCh = async (scores: ScoreRecordInsertType[]) => {
return await clickhouseClient().insert({
table: "scores",
format: "JSONEachRow",
values: scores,
});
};
@@ -0,0 +1,3 @@
export * from "./tracing-factory";
export * from "./clickhouse-helpers";
export * from "./org-factory";
@@ -0,0 +1,50 @@
import { v4 } from "uuid";
import { prisma } from "../../db";
import { hashSecretKey, getDisplaySecretKey } from "../auth/apiKeys";
export function createBasicAuthHeader(
username: string,
password: string,
): string {
const base64Credentials = Buffer.from(`${username}:${password}`).toString(
"base64",
);
return `Basic ${base64Credentials}`;
}
export type CreateOrgProjectAndApiKeyOptions = {
projectId?: string;
};
export const createOrgProjectAndApiKey = async (
props?: CreateOrgProjectAndApiKeyOptions,
) => {
const projectId = props?.projectId ?? v4();
const org = await prisma.organization.create({
data: {
id: v4(),
name: v4(),
},
});
await prisma.project.create({
data: {
id: projectId,
name: v4(),
orgId: org.id,
},
});
const publicKey = v4();
const secretKey = v4();
const auth = createBasicAuthHeader(publicKey, secretKey);
await prisma.apiKey.create({
data: {
id: v4(),
projectId: projectId,
publicKey: publicKey,
hashedSecretKey: await hashSecretKey(secretKey),
displaySecretKey: getDisplaySecretKey(secretKey),
},
});
return { projectId, publicKey, secretKey, auth };
};
@@ -1,8 +1,9 @@
import {
type ObservationRecordInsertType,
type TraceRecordInsertType,
} from "@langfuse/shared/src/server";
import { v4 } from "uuid";
import {
TraceRecordInsertType,
ObservationRecordInsertType,
ScoreRecordInsertType,
} from "../repositories/definitions";
export const createTrace = (trace: Partial<TraceRecordInsertType>) => {
return {
@@ -68,3 +69,23 @@ export const createObservation = (
...observation,
};
};
export const createScore = (score: Partial<ScoreRecordInsertType>) => {
return {
id: v4(),
project_id: v4(),
trace_id: v4(),
observation_id: v4(),
name: "test-score" + v4(),
timestamp: Date.now(),
value: 100.5,
source: "API",
comment: "comment",
data_type: "NUMERIC" as const,
created_at: Date.now(),
updated_at: Date.now(),
event_ts: Date.now(),
is_deleted: 0,
...score,
};
};
@@ -12,7 +12,7 @@ export function transformStreamToCsv(): Transform {
transform(
row: Record<string, any>,
encoding: BufferEncoding,
callback: TransformCallback
callback: TransformCallback,
): void {
if (isFirstChunk) {
// Extract headers from the first object
@@ -23,7 +23,7 @@ export function transformStreamToCsv(): Transform {
// Convert the object to a CSV line and push it
const csvRow = headers.map((header) => {
const field = row[header];
const field = row[header] ?? "";
let str = stringify(field);
// escape and format fields that contain commas
@@ -85,4 +85,10 @@ export const dashboardColumnDefinitions: UiColumnMapping[] = [
uiTableId: "version",
uiTableName: "Version",
},
{
clickhouseTableName: "observations",
clickhouseSelect: "provided_model_name",
uiTableId: "model",
uiTableName: "Model",
},
];
@@ -68,35 +68,39 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableId: "timeToFirstToken",
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(completion_start_time), NULL, date_diff('seconds', start_time, completion_start_time))",
"if(isNull(completion_start_time), NULL, date_diff('milliseconds', start_time, completion_start_time) / 1000)",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Latency (s)",
uiTableId: "latency",
clickhouseTableName: "observations",
clickhouseSelect:
"if(isNull(end_time), NULL, date_diff('seconds', start_time, end_time))",
"if(isNull(end_time), NULL, date_diff('milliseconds', start_time, end_time) / 1000)",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Tokens per second",
uiTableId: "tokensPerSecond",
clickhouseTableName: "observations",
clickhouseSelect:
"usage_details['input'] / date_diff('seconds', start_time, end_time)",
"(arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details))) / (date_diff('milliseconds', start_time, end_time) / 1000))",
},
{
uiTableName: "Input Cost ($)",
uiTableId: "inputCost",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'input'), cost_details), cost_details['input'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, cost_details)))",
},
{
uiTableName: "Output Cost ($)",
uiTableId: "outputCost",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'output'), cost_details), cost_details['output'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, cost_details)))",
},
{
uiTableName: "Total Cost ($)",
@@ -128,14 +132,14 @@ export const observationsTableUiColumnDefinitions: UiColumnMapping[] = [
uiTableId: "inputTokens",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'input'), usage_details), usage_details['input'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, usage_details)))",
},
{
uiTableName: "Output Tokens",
uiTableId: "outputTokens",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'output'), usage_details), usage_details['output'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details)))",
},
{
uiTableName: "Total Tokens",
@@ -71,28 +71,28 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
{
uiTableName: "Input Tokens",
uiTableId: "inputTokens",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'input'), usage_details), usage_details['input'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, usage_details)))",
},
{
uiTableName: "Output Tokens",
uiTableId: "outputTokens",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'output'), usage_details), usage_details['output'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, usage_details)))",
},
{
uiTableName: "Total Tokens",
uiTableId: "totalTokens",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
},
{
uiTableName: "Usage",
uiTableId: "usage",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'total'), usage_details), usage_details['total'], NULL)",
},
@@ -105,28 +105,29 @@ export const tracesTableUiColumnDefinitions: UiColumnMapping[] = [
{
uiTableName: "Latency (s)",
uiTableId: "latency",
clickhouseTableName: "traces",
clickhouseSelect: "latency_milliseconds",
clickhouseTableName: "observations",
clickhouseSelect: "latency_milliseconds / 1000",
// If we use the default of Decimal64(12), we cannot filter for more than ~40min due to an overflow
clickhouseTypeOverwrite: "Decimal64(3)",
},
{
uiTableName: "Input Cost ($)",
uiTableId: "inputCost",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'input'), cost_details), usage_details['input'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, cost_details)))",
},
{
uiTableName: "Output Cost ($)",
uiTableId: "outputCost",
clickhouseTableName: "traces",
clickhouseTableName: "observations",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'output'), cost_details), usage_details['output'], NULL)",
"arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, cost_details)))",
},
{
uiTableName: "Total Cost ($)",
uiTableId: "totalCost",
clickhouseTableName: "traces",
clickhouseSelect:
"if(mapExists((k, v) -> (k = 'total'), cost_details), usage_details['total'], NULL)",
clickhouseTableName: "observations",
clickhouseSelect: "cost_details['total']",
},
];
@@ -138,16 +138,16 @@ export const tracesTableCols: ColumnDefinition[] = [
},
];
// Used only for dataset evaluator form, not on any table
export const datasetOnlyCols: ColumnDefinition[] = [
{
name: "Dataset",
id: "datasetId",
type: "stringOptions",
internal: 'di."dataset_id"',
options: [], // to be filled in at runtime
},
];
export const datasetCol: ColumnDefinition = {
name: "Dataset",
id: "datasetId",
type: "stringOptions",
internal: 'di."dataset_id"',
options: [], // to be filled in at runtime
};
// Used only for dataset evaluator, not on dataset table
export const datasetOnlyCols: ColumnDefinition[] = [datasetCol];
export const evalTraceTableCols: ColumnDefinition[] = tracesOnlyCols;
export const evalDatasetFormFilterCols: ColumnDefinition[] = datasetOnlyCols;
@@ -3,6 +3,7 @@ export type UiColumnMapping = {
uiTableId: string;
clickhouseTableName: string;
clickhouseSelect: string;
clickhouseTypeOverwrite?: string;
queryPrefix?: string;
};
+36
View File
@@ -0,0 +1,36 @@
export function getIsCharOrUnderscore(value: string): boolean {
const charOrUnderscore = /^[A-Za-z_]+$/;
return charOrUnderscore.test(value);
}
export function extractVariables(mustacheString: string): string[] {
const mustacheRegex = /\{\{(.*?)\}\}/g;
const uniqueVariables = new Set<string>();
for (const match of mustacheString.matchAll(mustacheRegex)) {
uniqueVariables.add(match[1]);
}
for (const variable of uniqueVariables) {
// if validated fails, remove from set
if (!getIsCharOrUnderscore(variable)) {
uniqueVariables.delete(variable);
}
}
return Array.from(uniqueVariables);
}
export function stringifyValue(value: unknown) {
switch (typeof value) {
case "string":
return value;
case "number":
return value.toString();
case "boolean":
return value.toString();
default:
return JSON.stringify(value);
}
}

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